Rankion.ai API Reference

Stand: 2026-09-09 · Version: v1 · 562 Endpoints

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

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

Authentifizierung

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

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

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

Antwort-Formate

Credit-Mechanik

Jobs/Aktionen mit credits:N Middleware ziehen N Credits aus dem Team-Balance vor Job-Dispatch. Bei cache=false Refresh (Explorer) wird inline 50 Credits abgebucht.


🎯 AI Visibility Tracking

Projekte

Method URL Beschreibung Credits
GET /tracking-projects alle Team-Projekte
POST /tracking-projects/analyze Wizard Step 1+2: Domain/Keyword analysieren, dispatcht AnalyzeDomainJob. Body: {mode:"domain"|"keyword", domain?, keyword?, name?, language?, country?, own_domains[]?} → 202 + project_id
POST /ai-visibility/ingest Guided Prompt-Ingest (skill/agent/MCP front door). Schickt eine Prompt-Liste; State-Machine status: needs_input (fragt domain+market) → analyzing → needs_confirmation → activated. Body: {prompts[], domain?/brand?, language?, country?, project_id?, confirm?, auto_confirm?, platforms?, frequency?, start_run?}. Orchestriert analyze→activate→classify; team-scoped, idempotent (team,domain). Doku: docs/modules/ai-visibility-ingest.md analyze 5 / activate 0
GET /ai-visibility/ingest/{id} Poll des Ingest-/Analyse-Status (gleiche Envelopes). Team-scoped (403 cross-team)
GET /tracking-projects/{id} Detail mit KPIs
PUT /tracking-projects/{id} Settings: {name?, brand_name?, brand_aliases[], competitor_domains[], own_domains[], tracking_frequency?, llm_platforms[], llm_with_search?, llm_without_search?, track_aio?, reality_check_enabled?}llm_platforms[] erlaubt: chatgpt, perplexity, claude, gemini, copilot, grok, google_ai_mode — own_domains = weitere eigene Domains (Full-Replace, normalisiert, schließt Hauptdomain aus, reklassifiziert Cited Sources sofort)
DELETE /tracking-projects/{id} ⚠ Projekt samt aller Daten löschen (Keywords, Prompts, Runs, Scores, Cited-Sources, LLM-Results + alle CASCADE-Children). Irreversibel. Team-gescoped (403 cross-team). → 204 No Content nur eigenes Team · 0
GET /tracking-projects/{id}/analysis-status Wizard polling: {analysis_status, analysis_phase, analysis_error, suggested_keywords, suggested_competitors, competitor_discovery, competitor_rediscovery_status, competitor_rediscovery_error, suggested_prompts, suggested_personas, classification}. competitor_discovery unterscheidet ok | empty | failed; die beiden rediscovery-Felder tragen den Lebenszyklus von POST .../competitors/discover
POST /tracking-projects/{id}/activate Wizard Step 5 atomic: erstellt TrackingKeyword/Prompt/Competitor + flippt status=active. Body: {keywords[], competitors[], prompts[], platforms[], frequency, with_search?, without_search?, track_aio?, reality_check_enabled?, personas[]}platforms[] erlaubt: chatgpt, perplexity, claude, gemini, copilot, grok, google_ai_mode
POST /tracking-projects/{id}/run Tracking-Run starten (async)
GET /tracking-projects/{id}/runs Run-Historie (?limit=&offset=)
POST /tracking-projects/{id}/sync-sources Cited-Sources sync mit externem Tool (refresht zusätzlich in_portfolio-Flags via SyncPortfolioDomainsJob)
POST /tracking-projects/{id}/own-domains Domain als eigene übernehmen (Claim, Pendant zur Cited-Sources-Hover-Aktion). Body: {domain} — entfernt sie ggf. aus den Wettbewerbern, reklassifiziert bestehende Citations sofort. Returnt {domain, own_domains[]}; 422 bei ungültiger Domain
GET /ai-visibility/{id}/business-profile Business-Profil (Geo-Scope, Standort, Einzugsgebiet, Zielgruppen-Linse mit likely_queries) — {profile: null} solange keins gebaut
PATCH /ai-visibility/{id}/business-profile Profil korrigieren. Body: {geo_scope?, city?, service_area_cities[]?} — 422 ohne vorhandenes Profil; synct Legacy-Spalten
POST /ai-visibility/{id}/business-profile/refresh Profil neu analysieren (Multi-Page-Crawl + Klassifikation, async). 202; Poll via GET refresh_status 5
GET /ai-visibility/{id}/article-requests Partner-Outreach-Anfragen des Projekts (?per_page=)
POST /ai-visibility/{id}/article-requests Artikel-Platzierung beim Partner anfragen. Body: {domain, prompts[], keywords[], message?, consent:true}. 422 ohne consent, 409 wenn bereits aktive Anfrage für die Domain 0
DELETE /ai-visibility/{id}/article-requests/{requestId} Anfrage-Status entfernen (status=withdrawn)
GET /tracking-projects/{}/keyword-suggestions Listet Sitemap-basierte Keyword-Vorschläge, sortiert nach Suchvolumen, paginiert
POST /tracking-projects/{}/prompts/bulk-classify-intent Klassifiziert alle unklassifizierten Prompts asynchron nach Funnel-Stage (202)
POST /tracking-projects/{}/prompts/bulk-monitor Setzt Monitor-Status für mehrere Prompts per ID-Liste
GET /tracking-projects/{}/prompts/cost-preview Schätzt monatliche Monitoring-Kosten für aktive/ausgewählte Prompts
POST /tracking-projects/{}/prompts/refresh-volumes Aktualisiert Prompt-Suchvolumina asynchron per Job (202)
POST /tracking-projects/{}/prompts/{}/classify-intent Klassifiziert einzelnen Prompt synchron nach Funnel-Stage
PATCH /tracking-projects/{}/prompts/{}/monitor Setzt Monitor-Status eines Prompts (active/paused/excluded)
GET /tracking-projects/{}/sitemap-pages Listet gescannte Sitemap-Seiten des Projekts paginiert
POST /tracking-projects/{}/sitemap-scan Startet asynchronen Sitemap-Scan, liefert entdeckte Sitemap-URL (202)

Freigaben (Cross-Team-Sharing)

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

Method URL Beschreibung Auth · Credits
GET /tracking-projects/{id}/shares Freigaben eines Tracking-Projekts auflisten (Status, eingeladene E-Mail, Berechtigung) nur Eigentümer · 0
POST /tracking-projects/{id}/shares Freigabe anlegen (Einladung). Body: {email} — lädt einen bestehenden Nutzer mit permission:"write" ein. Idempotent (reaktiviert abgelaufene/terminierte Freigaben auf derselben Zeile). Stellt via „Mit mir geteilt" + E-Mail (TrackingProjectShared) zu nur Eigentümer · 0
DELETE /tracking-projects/{id}/shares/{share} Freigabe widerrufen (status→revoked) nur Eigentümer · 0
GET /shared-with-me Mit dem authentifizierten Nutzer geteilte Projekte (Gast-Sicht) jeder · 0
POST /tracking-project-shares/{share}/accept Freigabe annehmen (E-Mail-Match, atomarer lockForUpdate-Consume → status→active) nur Eingeladener · 0
POST /tracking-project-shares/{share}/decline Freigabe ablehnen (status→declined) nur Eingeladener · 0
DELETE /tracking-project-shares/{share} Freigabe entkoppeln/verlassen (status→detached) nur Eingeladener · 0

Keywords / Prompts

Method URL Notes
GET /tracking-projects/{id}/keywords ?active_only=true
POST /tracking-projects/{id}/keywords Body: {keyword, keyword_group?, tags?, search_volume?, cpc?, difficulty?, search_intent?, language?, country?, source?} · sourcemanual|sitemap|suggestion|imported (Plan #87); is_active wird nicht validiert und daher ignoriert
PUT /tracking-keywords/{id}
DELETE /tracking-keywords/{id}
GET /tracking-projects/{id}/prompts
POST /tracking-projects/{id}/prompts Body: {prompt_text, prompt_category?, persona_label?, commercial_intent?}
PUT /tracking-prompts/{id}
DELETE /tracking-prompts/{id}
POST /tracking-projects/{id}/prompts/extract Gibt ALLE (oder Filter-Subset) Prompts des Projekts im Response zurück (Backup/Export → Excel/CSV) UND löscht sie atomar — 1 Call statt N Einzel-DELETEs. Filter (optional, AND): prompt_ids[], active_only, category. → {extracted[], deleted_count}
POST /tracking-projects/{id}/prompts/import CSV/Excel Bulk-Import. multipart/form-data: file, optional mapping[col_name]=index. → {imported, skipped, errors, mapping_used}
POST /tracking-projects/{id}/prompts/{promptId}/classify-persona Persona EINES Prompts per AI setzen (constrained auf target_audiences[].label, sync). → 200 + {tracking_prompt_id, persona_label, classified_at}. is_user_edited-Personas geschützt · 0
POST /tracking-projects/{id}/prompts/bulk-classify-persona Alle Prompts ohne persona_label projektweit klassifizieren (dispatcht BackfillPersonaJob, generiert ggf. zuerst Personas, async). → 202 + {tracking_project_id, pending_classifications, status:"queued"} · 0

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

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

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

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

Cited Sources

Method URL Notes
GET /tracking-projects/{id}/cited-sources ?domain=&outreach_status=&is_own_domain=&sort=&per_page=. Zeile enthält in_portfolio (bool, „Anfrage möglich"), citation_possibility_score (int, persistierter Floor 95 für Portfolio-Domains) + effective_possibility (int, identischer Read-Time-Wert)
GET /tracking-projects/{id}/cited-sources/export CSV-Stream. ?from=&to=&status=&platform=&search=&sort=
POST /tracking-projects/{id}/cited-sources/analyze Citation-Analyse dispatchen. Body optional {source_ids:[]}. Default: alle unanalyzed. → 202 + {queued, estimated_seconds}
PATCH /tracking-projects/{id}/cited-sources/{source_id} Body: {outreach_status?:neu|anfrage_gestellt|verlinkt|abgelehnt|ignoriert, notes?}

Insights

Method URL Notes
GET /tracking-projects/{id}/scores Score-Historie (?from=&to=)
GET /tracking-projects/{id}/platform-breakdown (?from=&to=)
GET /tracking-projects/{id}/insights/low-hanging-fruit (?days=30&min_possibility=60&limit=5)
GET /tracking-projects/{id}/insights/platform-gap (?days=30)
POST /tracking-projects/{id}/insights/platform-gap/{platform}/diagnose async — 1 Credit
GET /tracking-projects/{id}/insights/potential-hero {eligible:false} wenn <20 Abfragen
GET /tracking-projects/{id}/off-page-readiness Off-Page-/Demand-Achsen-Score — 0 Credits, team-scoped. Returns {score:int|null, data_available, components:{geo.offpage.brand_demand, geo.offpage.third_party_mentions, geo.offpage.source_affinity → {score, data_available, meta, weight}}, meta}. score=null=noch keine Daten (≠0). 403 Fremd-Team, 404 unbekannt. SSOT OffPageReadinessScorer (bridged community_project_idtracking_project_id). Modul: geo-criteria-catalog
POST /tracking-projects/{id}/prompts/{prompt}/generate-brief Content-Brief via Claude — 3 Credits
POST /tracking-projects/{id}/brand-aliases Body: {alias}
GET /tracking-projects/{id}/competitors Gemeinsame Wettbewerberliste des Projekts. Returnt {competitors:[{domain, brand_name, source, source_url, is_manual, is_active}]}, nach Domain sortiert — inklusive der abgewaehlten (is_active:false), damit ein Aufrufer die Abwahl sieht statt eine Luecke zu raten. source: wizard | api | analysis | null (von Hand). Reicher als das flache competitor_domains aus GET /tracking-projects/{id}
PATCH /tracking-projects/{id}/competitors Body: {domains[]} (max 50) — normalisiert/dedupliziert, synct competitor_domains + TrackingCompetitor. Returnt {competitor_domains[]}. Powert das Gap-Wettbewerber-Modal
POST /tracking-projects/{id}/competitors/discover Wettbewerber neu ermitteln lassen — EIN OpenAI-Aufruf mit Websuche, ohne den ganzen Einrichtungslauf. Body optional {limit} (1-30, Vorgabe 10). 202 {status:'queued', tracking_project_id, credits_required, poll_url}; 503 discovery_unavailable, wenn kein OpenAI-Zugang eingerichtet ist. Fortschritt ueber GET /tracking-projects/{id}/analysis-statuscompetitor_rediscovery_status (queued | running | completed | failed) und competitor_discovery (ok | empty | failedempty heisst gesucht und nichts gefunden, failed heisst nicht suchen koennen). Das Ergebnis ist ein Vorschlag in suggested_competitors; die bestaetigte Liste bleibt unangetastet, bestaetigt wird ueber PATCH .../competitors. Abgezogen wird NACH Erfolg — 5 Credits

Brand-Accuracy (Faktencheck)

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

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

Reality Check (AI Visibility Index)

Method URL Notes
GET /tracking-projects/{id}/avi neuester AVI-Score, tier (red/yellow/green), 6-Dim-Scores, recommendations. 404 wenn kein Run
GET /tracking-projects/{id}/dimensions alle 6 Dimension-Scores pro Plattform
POST /tracking-projects/{id}/reality-check-report PDF-Generierung async. 404 wenn kein Run
GET /tracking-projects/{id}/reality-check-report/status Polling-Endpoint für die laufende Report-Generierung: {status: queued|running|ready|failed, progress?, download_url?}

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

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

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

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


📝 Articles

CRUD

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

Website-Import (Plan-Hub #32)

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

Method URL Credits
POST /projects/{project}/article-import/scan — Body optional {domain?} (Default: project.domain). Dispatched den Scan-Job, 202 mit {scan_id, status:"queued"} 5 (charge-on-success, kein Treffer → keine Abbuchung)
GET /projects/{project}/article-import/scans/{scan} — Status pollen (pending|running|completed|failed|no_results) + candidates_found
GET /projects/{project}/article-import/scans/{scan}/candidates — gefundene Kandidaten, paginiert (url, title, detected_published_at, detected_modified_at, status)
POST /projects/{project}/article-import/scans/{scan}/import — Body {candidate_ids:[]}. Erzeugt Article-Rows für die ausgewählten, noch nicht importierten Kandidaten. Returns {imported, skipped_already_imported} (bereits importierte werden übersprungen, kein Doppel-Import) 0 (Kosten fielen beim Scan an)

Aktionen

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

Versions

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

Freshness

Method URL
GET /articles/{id}/freshness
POST /articles/{id}/freshness/check (read-only score, 0 credits — non-destructive)
POST /articles/{id}/freshness/refresh (AI-rewrite → DRAFT + version backup, contentRefresh credits — never auto-publishes)
GET /articles/{id}/freshness/history

Internal Linking

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

Standalone

Method URL Credits
POST /generate/article 5
POST /generate/image 5

CMS-Integrations

Method URL Credits
POST /cms-integrations/{id}/test-connection — prüft die hinterlegten Credentials live (WordPress /users/me, Shopify /shop.json). Reiner Probe-Call (keine Persistenz, idempotent), Throttle 20/min. 200 mit {data:{ok:bool, detail}}; 404 fremdes Team; 422 nicht-unterstützter CMS-Typ

🛬 Landingpages

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

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

🔍 SEO Suite

Rank Tracker (Phase 1)

Method URL
GET /rank-tracker
GET /rank-tracker/{project}
GET /rank-tracker/{project}/keywords (?device=&country=&is_active=)
POST /rank-tracker/{project}/keywords Body: {keywords[], devices[]} async 202
POST /rank-tracker/{project}/refresh
GET /rank-tracker/keywords/{keyword}/history (30d/90d/365d)

Keyword Explorer (Phase 2)

Method URL Credits
POST /explorer Body: {seed, language?, country?, cache?}. cache=false umgeht 60-min-Cache 50
GET /explorer/searches Such-Historie des Teams (paginiert, ?page=&per_page= max 100). Items: {id, keyword, language, country, created_at, searched_at} 0
POST /explorer/searches/{id}/refresh Refresh eines History-Eintrags: frischer Pull (Cache-Bust), 202 + {keyword, language, country}. Abrechnung bei Job-Erfolg 50
DELETE /explorer/searches/{id} History-Eintrag löschen (204; gecachte Ergebnisse bleiben). 404 bei fremdem Team 0
GET /explorer/{keyword} Volume + KD + Trends + SERP. ?include=related,questions inlined die Listen. ?cache=false erzwingt fresh fetch 0 / 50*
GET /explorer/{keyword}/related ?type=related|question. ?cache=false 0 / 50*

Keyword Gap (Phase 3)

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

Site Audit (Phase 4)

Method URL
GET/POST /site-audit
GET /site-audit/{crawl} · .../issues
PATCH /site-audit/{crawl}/issues/bulk
PATCH /site-audit/issues/{issue}
POST /site-audit/{crawl}/abort
POST /site-audit/issues/{issue}/generate-brief
GET /site-audit/{}/report.pdf
GET /site-audit/{}/export

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

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

Bulk-mark — PATCH /site-audit/{crawl}/issues/bulk. Throttle: 30/min. Body:

{
  "filter": {
    "issue_type": "missing_alt_text",
    "severity": "high",
    "status": "open"
  },
  "new_status": "fixed"
}

All filter.* fields are optional. filter.status is the FROM-state to match (only flip rows currently in this status). new_status is required and must be one of open|dismissed|fixed. Response: {data:{affected:int, new_status}, meta:{applied_filter:{...}}}.

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

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

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

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

Format grounding_<framework>_<category>_<code>. Typical codes (15 detector rules in v1.5-gp framework):

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

Method URL Beschreibung
GET /backlinks · /backlinks/{project} · .../domains · .../anchors · .../events Phase 5 base
POST /backlinks Activate-Endpoint: Backlink-Tracking für Projekt aktivieren (Subscription required)
POST /backlinks/{project}/refresh Trigger Delta-Pull Job
POST /backlinks/{project}/full-refresh Vollständigen DFS-Re-Pull triggern (Async 202)
GET /backlinks/{project}/spam-scores Toxische Domains (Filter ?min_spam=60)
POST /backlinks/{project}/spam-scores/refresh Queue EnrichSpamScoresJob (202)
GET /backlinks/{project}/disavow-export text/plain Disavow.txt (Param ?threshold=80)
GET /backlinks/{project}/timeseries Historische Velocity-Punkte (?granularity=daily|weekly|monthly)
POST /backlinks/{project}/anchors/refresh Queue PullDetailedAnchorsJob (202)
GET /backlinks/{project}/links TODO-050 Per-Link-Browser: einzelne Backlinks (source_url→target_url, anchor, follow). Filter ?sort=last_seen|first_seen|source_trust_flow|anchor_text, ?follow=dofollow|nofollow, ?lost=1. Credits 0
GET /backlinks/{project}/lost-links TODO-050 Verlorene/gelöschte Einzel-Links (is_deleted OR lost_at), sortiert nach lost_at desc. Credits 0
GET /backlinks/{project}/disavow-recommendations TODO-050 Top toxic_severity=high Referring-Domains + Gründe (Disavow-Empfehlung; vom Action-Center/Agent zitierbar). Credits 0
POST /backlinks/{project}/link-gap TODO-050 Competitor-Link-Gap starten (Async 202). Body {competitor_domains?:[]} (Default = TrackingProject competitor_domains). Credits 10 (backlink_link_gap)
GET /backlinks/{project}/link-gap TODO-050 Letzten Link-Gap-Lauf lesen (processing_status → results[]). Credits 0
POST /backlinks/{project}/competitor-overlap Wettbewerber-Overlap starten (Async 202). Body {competitor_domains?:[] (Default = TrackingProject, max 12), min_matches?:1..5 (default 2)}. Reicher als Link-Gap: competitor_matches, links_me („+ich"), N×N competitor_pair_overlap-Matrix. Credits 15 (backlink_competitor_overlap)
GET /backlinks/{project}/competitor-overlap Letzten Overlap-Lauf lesen — server-seitig gefiltert/sortiert/paginiert. Query ?only_gaps=1 (default) &min_matches=2&only_clean=1&min_tf=30&sort=matches|trust_flow&per_page=50&page=1. Kein Lauf → {data:null} (200, kein 404). Credits 0

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

Method URL Beschreibung
GET /backlinks/by-domain/{domain} Summary (rank, total backlinks, referring domains, dofollow ratio, spam score)
POST /backlinks/by-domain/{domain}/pull DFS-Pull triggern (Async 202)
POST /backlinks/by-domain/{domain}/enrich-spam-scores Spam-Score-Enrichment für die Top-Domains (Async 202)
GET /backlinks/by-domain/{domain}/domains Referring Domains (Filter: ?only_active, ?only_toxic, ?sort)
GET /backlinks/by-domain/{domain}/anchors Anchor-Verteilung
GET /backlinks/by-domain/{domain}/spam-scores Toxische Domains (Filter ?min_spam=60)
GET /backlinks/by-domain/{domain}/disavow-export Google-Disavow.txt (?threshold=80)
GET /backlinks/by-domain/{domain}/timeseries Velocity-History (?granularity=daily|weekly|monthly)
GET /backlinks/by-domain/{domain}/links TODO-050 Per-Link-Browser by-domain (einzelne Backlinks; ?sort, ?follow, ?lost=1). Credits 0
GET /backlinks/by-domain/{domain}/lost-links TODO-050 Verlorene Einzel-Links by-domain. Credits 0
GET /backlinks/by-domain/{domain}/disavow-recommendations TODO-050 High-Toxicity Disavow-Empfehlungen by-domain. Credits 0
POST /backlinks/by-domain/{domain}/link-gap TODO-050 Competitor-Link-Gap by-domain starten (Async 202). Body {competitor_domains:[]}. Credits 10 (backlink_link_gap)
GET /backlinks/by-domain/{domain}/link-gap TODO-050 Letzten Link-Gap-Lauf by-domain lesen. Credits 0
POST /backlinks/by-domain/{domain}/competitor-overlap Wettbewerber-Overlap by-domain starten (Async 202). Body {competitor_domains:[] (Pflicht), min_matches?:1..5 (default 2)}. Credits 15 (backlink_competitor_overlap)
GET /backlinks/by-domain/{domain}/competitor-overlap Letzten Overlap-Lauf by-domain lesen (Query ?only_gaps=1&min_matches=2&only_clean=1&min_tf=30&sort=matches|trust_flow&per_page=50). Kein Lauf → {data:null}. Credits 0
Method URL Beschreibung
GET /teams/current/backlinks-subscription Aktive Subscription des Teams (Tier, Limits, Renewal)
POST /admin/teams/{team}/backlinks-subscription Admin-Override: Subscription für anderes Team setzen (Body: {tier, limits?})

🐍 Sandbox (Chat Python-Analyse)

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

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

Request (POST /sandbox/runs):

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

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

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

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

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


🛠 Keywords (Research & Expansion)

Method URL Credits
GET /projects/{project}/keywords
POST /projects/{project}/keywords
POST /projects/{project}/keywords/import 10/100 KW bei cluster
POST /projects/{project}/keywords/import/csv 10/100 KW bei cluster
POST /projects/{project}/keywords/ai-research 5
GET /projects/{project}/keyword-researches
GET /keyword-researches/{id}/status
GET /keyword-researches/{id}/results.csv
DELETE /keywords/{id}
POST /keywords/research 5
POST /keywords/{id}/expand 10
GET /keywords/{id}/expansions

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

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

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

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

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

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

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


✍️ Content Intelligence

Storylines

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

Style Profiles

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

Knowledge Base

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

Orphan Scans

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

Overview

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

📅 Calendar & Images

Calendar

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

Project Images (legacy per-project listing)

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

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

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

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

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

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

🤖 AI Tools

Agentic Chat — Master Agent als Endpoint

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

Body:

Query-Parameter:

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

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

Hinweise:

AI Scanner / Detection

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

Humanizer

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

Content Optimizer

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

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

POST /v1/content-optimizer/analyze returns 202 mit:

{
  "message": "Content optimization started",
  "id": 6,
  "optimization_id": 6,
  "poll_url": "/api/v1/content-optimizer/6"
}

Plus Header Location: /api/v1/content-optimizer/6.

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

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


🥊 Competitor Analysis

Method URL Credits
GET/POST /competitor-analyses 20 (POST)
GET /competitor-analyses/{id} · .../gaps (?is_favorite=&is_dismissed=) — competitors[] je Wettbewerber mit Majestic-Autorität trust_flow / citation_flow / ref_domains / authority_checked_at (null bis angereichert)
PATCH /competitor-analyses/{id} Body {name} (umbenennen)
DELETE /competitor-analyses/{id} (inkl. Gaps, 204)
PATCH /competitor-analyses/gaps/{id} Body {is_favorite?, is_dismissed?}
POST /competitor-analyses/{id}/rerank-competitors

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

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

Method URL Credits
GET/POST /content-audits 10 (POST)
GET /content-audits/{id} · .../export · .../pages
GET /content-audits/{id}/compare
PATCH /content-audits/{id}/pages/bulk
GET /content-audit-pages/{id}
PATCH /content-audit-pages/{id}/solved
POST /content-audit-pages/{id}/refresh 1

Audits-listing — filters on GET /content-audits. Query params:

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

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

Pages-listing — filters on GET /content-audits/{id}/pages. Query params:

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

Audit-over-time compare — GET /content-audits/{id}/compare. Read-only, 0 credits, runs no new crawl (pure read of the persisted summary + page issues). Compares {id} (current) against a baseline:

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

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

Bulk-mark — PATCH /content-audits/{id}/pages/bulk. Throttle: 30/min. Body:

{
  "filter": {
    "priority": "low",
    "status": "open",
    "min_seo_score": 0,
    "max_seo_score": 50,
    "issue_type": "error"
  },
  "is_solved": true
}

All filter.* fields are optional. filter.status is the FROM-state to match (only flips rows currently in this state). is_solved is required and is the new state to write. Response: {data:{affected:int, is_solved:bool}, meta:{applied_filter:{…}}}. Team-scoped via audit.team_id — cross-team access returns 403.

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


🧑‍🤝‍🧑 Community (Mitglieder)

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

Method URL Credits
GET /community/members — Filter: q, membership_level (std|pro), status (aktiv|gesperrt|unbestaetigt), per_page (max 100), page. Antwort: {data:[…], meta:{current_page, per_page, total, last_page}}
GET /community/members/{member} — ein Mitglied. 404 bei unbekannter Nummer.
GET /community/members/{member}/membership — die Mitgliedschaft eines Mitglieds: Stufe, Status, Laufzeitende, Abo-Kennung. is_running beantwortet die eigentliche Frage »läuft sie?« und schließt active, trialing und past_due ein. Nur lesend — eine Mitgliedschaft entsteht durch eine Zahlung; sie hier zu setzen ließe Katalog und Stripe auseinanderlaufen.

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

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

Community — Stufen-Katalog (OFFEN, ohne Token)

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

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

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


👥 Community Monitor

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

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

📈 Site Monitor

Method URL
GET/POST /site-monitor
GET/PUT/DELETE /site-monitor/{id}
POST /site-monitor/{id}/pause · .../resume · .../check-now
GET /site-monitor/{id}/checks · .../incidents · .../lighthouse

🚀 Automation

Bulk Generations

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

Autopilot

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

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

Pipelines

Method URL Credits
GET/POST /pipelines Body: {name, stages[]} 20 (POST)
GET /pipelines/{id}
POST /pipelines/{id}/retry

🏗 Projects & Settings

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

POST /projects Body: {name, domain?, language?, description?, country?, additional_countries[]?}country ist der einzelne Primärwert (projects.country, unverändert). additional_countries[] (max 10, je ISO-3166-1 alpha-2) legt/nutzt pro Land ein setup-Tracking-Gerüst (tracking_projects+tracking_keywords-Platzhalterzeile) an, siehe docs/modules/tracking-projects.md. Antwort trägt dann zusätzlich additional_markets: {registered[], skipped[], reason}reason ist no_domain (Projekt ohne Domain) oder active_tracking_project (Domain hat bereits aktives Tracking, kein Platzhalter wird angelegt); das Feld fehlt ganz, wenn keine additional_countries gesendet wurden.


🎯 Action Center

Method URL
GET/POST/PATCH /action-center/goal-profile
GET /action-center/goal-templates · .../industry-templates · .../seo-detectors
GET /action-center/path · /action-center/paths
POST /action-center/path/recompute
GET/POST /action-center/todos
GET/DELETE /action-center/todos/{id}
GET /action-center/todos/{id}/outcome — measured outcome (baseline vs realized, 14d delta) · 0 credits
POST /action-center/todos/{id}/complete · .../snooze
POST /action-center/todos/{}/move
POST /action-center/todos/{}/notes

🔌 Google Integrations

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

⭐ Review Sources

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

📰 Blog (Admin)

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

💾 Rankion OS

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

💳 Account / Meta

Method URL
GET /team
GET /credits · /credits/history
GET /dashboard
GET /admin/api-costs (Admin only)
GET /admin/usage-analytics
GET /admin/online-users (Admin only) — wer ist gerade angemeldet: Name, E-Mail, letzte Aktivitaet je Konto, zuletzt aktiv zuerst, je Konto GENAU EINE Zeile (Plan-Hub #70). Dieselbe Quelle und derselbe 60-s-Zwischenspeicher wie die Zahl auf /admin. Optional window_minutes (1–60, Vorgabe 5). Liefert {data:[{id,name,email,last_activity_at,last_active_seconds_ago}], meta:{total,window_minutes,generated_at}}. Nicht paginiert — die Menge ist durch das Zeitfenster begrenzt. Keine IP-Adresse, kein User-Agent (Datensparsamkeit).
GET /admin/promo-codes (Admin only) — Liste aller Promo-Codes mit Status (aktiv/abgelaufen/ausgeschöpft/deaktiviert) + fertiger register_url ({app-url}/register?promo=CODE)
GET /admin/promo-codes/redemptions (Admin only) — Einlösungen, filterbar per year/month/day/per_page
GET /admin/modules (Admin only) — alle Module + plattformweiter Admin-Sperrzustand (Plan-Hub #41, siehe module-store.md)
POST /admin/modules/{module}/state (Admin only) — Sperrzustand setzen (state: active/disabled/hidden; 422 Kernmodul/unbekannt)
POST /admin/invoices/{invoice}/cancel (Admin only) — Rechnung ueber die Lexware-API stornieren (Plan-Hub #62). Body {reason} (Pflicht, 3–500 Zeichen) — geht in den Belegtext der Stornorechnung und ins Admin-Protokoll. Erzeugt eine mit der Rechnung VERKNUEPFTE, finalisierte Gutschrift (POST /v1/credit-notes?finalize=true&precedingSalesVoucherId=…); die Public API hat keinen Void-Endpoint. Reine Buchhaltung: Credits, Plan und Abo bleiben unangetastet. Idempotent — ein zweiter Aufruf erzeugt keinen zweiten Beleg. 422 not_cancellable (bezahlt, bereits storniert oder ohne Lexware-Verknuepfung), 502 lexware_error (Lexware lehnt ab, z. B. Beleg nicht open — es entsteht dann KEIN Beleg). Antwort enthaelt credit_note_number und credit_note_pdf_url. Throttle 20/min.

🧾 Rechnungsdaten

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

Method URL Beschreibung Credits
GET /billing/profile Rechnungsprofil lesen. Leeres Shape, wenn noch keins existiert
PUT /billing/profile Rechnungsprofil schreiben (validiert; USt-ID-Format je EU-Land; triggert Stripe-Customer-Sync). Gate: manage settings (Owner/Admin). Body: {company_name, address_line1, postal_code, city, country, contact_name?, address_line2?, vat_id?, billing_email?, purchase_order_ref?, separate_billing_address?, billing_address_line1?, billing_postal_code?, billing_city?, billing_country?}
POST /billing/invoice-upgrade Upgrade auf einen bezahlten Plan auf Rechnung (ohne Stripe): Plan sofort aktiv, offene PDF-Rechnung + Info-Mail an den Betreiber. Voraussetzung: vollständiges Rechnungsprofil (sonst 422) + hinterlegte Bankverbindung. Gate: manage settings. Body: {plan: starter|pro|agency, cycle: monthly|yearly} → 201 {invoice_id, invoice_number, plan, billing_cycle, amount, currency, status, due_date}
POST /billing/subscription/cancel Abo zum Ende der bezahlten Periode kündigen. Bewusst kein Sofort-Storno: die laufende Periode ist bezahlt und bleibt nutzbar, es wird nichts erstattet. Nur die Eigentümerin/der Eigentümer des Teams (sonst 403); ohne aktives Abo 404. → 200 {status: cancelled_at_period_end, plan, billing_cycle, access_until}. Throttle 5/min

Workflow (GEO-Optimizer-Perspektive)

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

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

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

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


👥 Team-Mitglieder

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

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

Workflow (GEO-Optimizer-Perspektive)

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

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

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

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


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

Auditiert eine einzelne Landingpage: 3 Source-Screenshots (Desktop/Tablet/Mobile mit echter UA-Emulation), Lighthouse-Metriken, Opus 4.7 Multimodal-Vision-Analyse plus bis zu 3 gpt-image-2 KI-Renders der optimierten Variante (Desktop ist die Design-Basis, Tablet+Mobile sind responsive Adaptionen). Authentifizierung: Authorization: Bearer <token>, alle Endpoints team-scoped.

Method URL Credits Beschreibung
POST /page-audit 30 + 15 Audit starten. Body: {url, tracking_project_id?, persona?} → 202 + {id, status:"pending", url}
GET /page-audit/{id} Detail mit 2 Bild-URLs (Desktop-Source + Desktop-Ideal) + opus_analysis + Lighthouse
GET /page-audits Paginierte Liste. ?per_page=25&tracking_project_id=

Pipeline-Status: pendingscrapingscreenshottinganalyzingcompleted. Der Desktop-KI-Render läuft in einem separaten Background-Job nach completed; ideal_screenshot_url füllt sich anschließend.

Polling-Pattern:

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

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

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

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

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

Use-Cases:


📑 Reports & Cross-Module Correlation

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

Reports

Method URL Credits Beschreibung
POST /tracking-projects/{id}/generate-report 10 Report generieren (async). 202 + {report_id, tracking_project_id, status, status_endpoint}. Rate-Limit: 1 Request / 30 Min / Project — sonst 429 {error:"rate_limited"}.
GET /tracking-projects/{id}/reports Letzte 50 Reports des Projects (sortiert nach created_at desc).
GET /reports/{id} Report-Detail inkl. markdown_content und summary_json.

Status-Werte: pendinggeneratingcompleted (oder failed).

Polling-Pattern:

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

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

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

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

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

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

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

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

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

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

Cross-Module Correlation

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

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

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

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

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


Beispiele (curl)

Wizard-Flow komplett über API

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

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

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

Keyword Explorer mit allen Daten

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

Article generieren + publishen

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

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

Cited-Sources Outreach

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

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

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

Konventionen

Routes-Generierung

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

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

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

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

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

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


Chat Shares

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

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

POST /v1/chat-shares

Body:

Response 201:

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

PATCH /v1/chat-shares/{id}

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

DELETE /v1/chat-shares/{id}

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

Public route (NOT exposed via API)

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


Agentic Plans

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

Method URL Description Credits
GET /v1/agentic/sessions/{session}/plans Plan history for a session (paginated 50/page, ordered desc by created_at) 0
GET /v1/agentic/plans/{plan} Plan detail (status, denormalized counters, brainstorm_summary) 0
GET /v1/agentic/plans/{plan}/steps All steps with output_summary, output_data (≤64kB JSON), tool_calls_made, credits_used, latency_ms, retry_count, last_error, subagent_session_id 0
POST /v1/agentic/feedback Persists 👍/👎 feedback on an assistant message. Body: {message_id: int, direction: 'up'|'down'}. Idempotent upsert keyed by (user_id, message_id) — second click overwrites direction. Returns 404 unknown_msg, 403 not_yours, 422 invalid direction. Response: {ok: true, direction} 0

Plan response shape

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

Step response shape

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

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

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

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

GET /api/v1/openapi.yaml

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

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

Schema-Coverage:

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

Error-Code-Vokabular (kanonisch)

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

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

Rate-Limit-Headers

Jeder Endpoint mit throttle:N,M Middleware liefert auf erfolgreiche Responses:

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

Throttle-Werte pro Endpoint stehen in der Endpoint-Tabelle der jeweiligen Sektion (oben). Aktuelle Werte:


🤖 Master-Agent Härtung (Subsystem C)

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

P1.4 — Strukturierter context_overflow Error

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

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

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

P1.5 — tool_policy im Request

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

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

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

P1.6 — Async-Default + Polling-Endpoint

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

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

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

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

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

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

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

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

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

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


🧭 Grounding Audit Pipeline (Subsystem A)

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

Frameworks:

Method URL Beschreibung Credits
POST /v1/grounding/analyze Single-URL-Audit dispatchen. Body: {url, frameworks?, tracking_project_id?}. Throttle: 30/min. Returns 202 + {audit_id, status, estimated_seconds, poll_url} + Location: Header. 1
GET /v1/grounding/audits/{id} Polling/Result. Returns {audit_id, status, score, tier:{name,threshold_min,threshold_max}, frameworks{}, findings[], raw_text, duration_ms, credits_used, started_at, completed_at, expires_at, ui_url}. ui_url (string) = relativer UI-Pfad /grounding/audits/{id} — kanonischer Detail-Link fürs Team (nie selbst konstruieren). 404 (audit_not_found), 410 (audit_expired). 0
GET /v1/grounding/audits Liste eigener Audits (jede Row inkl. ui_url). Filter: ?status=, ?tracking_project_id=, ?from=, ?to=, Pagination. 0
POST /v1/grounding/batch Bulk-Audit für 1–500 URLs. Body: {urls[], frameworks?, concurrency? (1-10), callback_url?, tracking_project_id?}. Throttle: 5/min. Returns 202 + {batch_id, url_count, callback_secret, eta_seconds, poll_url, results_url}. callback_secret einmalig. url_count
GET /v1/grounding/batches/{id} Batch-Status + Counters. Returns {batch_id, status, source, completed, failed, total, concurrency, eta_seconds, results_url, callback?, started_at, completed_at}. 0
GET /v1/grounding/batches/{id}/results.ndjson NDJSON-Stream completed/failed Audit-Rows. Streamt während Lauf. 0
POST /v1/grounding/sitemap-audit Sitemap-Audit. Body: {sitemap_url, frameworks?, filter:{path_contains?,limit? (max 500),priority_min?}, concurrency?, callback_url?, tracking_project_id?}. Credits werden NACH Sitemap-Parse abgezogen. url_count_after_parse

Tier-Schwellen

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

Findings-Schema

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

Webhook-Verifikation

Server POSTet zu callback_url mit Header X-Rankion-Signature: sha256=<hex> (HMAC-SHA256 über Body, Key = callback_secret) + X-Rankion-Webhook-Id: <uuid>. 4 Retries (60/300/1800/7200s) bei 5xx oder 429. Verifikation:

$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $callbackSecret);
hash_equals($expected, $request->header('X-Rankion-Signature'));

Error-Codes (Subsystem A)

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

Beispiel — Single-URL Audit:

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

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

Beispiel — Sitemap-Batch:

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

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

🧬 GEO Criteria Catalog

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

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

Response-Felder (/v1/geo-criteria):

Off-Page-Readiness (Demand-Achse)

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

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

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

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

📚 Wiki

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

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

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

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

🧠 Deep Insights (LLM Data Mining)

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

Method URL Beschreibung Credits
GET /v1/data-mining/insights/{project} Voller 12-Sektionen-JSON-Payload (alle Sections in einer Response). 0
POST /v1/data-mining/insights/{project}/refresh Triggert Insights-Report-Generierung (PDF + CSV-ZIP-Bundle, asynchron). HTTP 202. 0
GET /v1/data-mining/insights/{project}/section/{section} Einzel-Section-Lazy-Load. {section}executive_summary, fact_errors, consensus_narratives, consensus_clusters, persona_gaps, battle_cards, outreach_top50, competitor_cooccurrence, early_warnings, source_whitelist, forecast_6w, recommended_actions. 0
GET /v1/data-mining/exports/{project}.pdf Letzten generierten PDF-Export downloaden. 404 falls noch keiner existiert (vorher refresh aufrufen). 0
GET /v1/data-mining/exports/{project}.zip Letztes CSV-Bundle (1 CSV pro Section, gezippt). 404 wenn nicht vorhanden. 0

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

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

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

🧬 Citation Causality (CCE)

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

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

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

Skill-Caller-Idiom (für Master-Agent / Claude Code Skill / externe Agenten via REST):

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

Skill-Caller-Idiom Plan-5-Phase-E (Insight-to-Action über REST):

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

Live-Dashboards:


⚡ Prefetch (Rankion Turbo)

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

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

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


🔐 Account / Sicherheit

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

Method URL Beschreibung Credits
GET /account Sicherheitsstatus: {id, name, email, email_verified, two_factor_enabled, auth_provider, pending_email}. Enthält NIEMALS Secrets (kein two_factor_secret, keine recovery_codes, kein pending_email_token).
PUT /account/password Passwort ändern. Body: {current_password, password, password_confirmation}. current_password ist serverseitig Pflicht (422 sonst) — ein Token allein reicht NICHT. Google-Konten → 422 (Passwort per Reset-Link). throttle:10,1.
DELETE /account/sessions Beendet alle anderen Sitzungen des Users (löscht nur dessen eigene sessions-Rows außer der aktuellen). Response: {message, deleted}. throttle:10,1.
GET /account/notification-preferences Benachrichtigungs-Präferenz-Matrix (Default-aufgelöst): {data:{email:{reports,tracking_alerts,job_results}, inapp:{…}, locked:{billing,security,team}}}. email/inapp = abbestellbare Kategorien als bool (Default true); locked = immer-aktive Kategorien.
PUT /account/notification-preferences Präferenzen setzen. Body: {email?:{reports?:bool, tracking_alerts?:bool, job_results?:bool}, inapp?:{…}}. NUR optionale Kategorien werden berücksichtigt (nicht-optionale billing/security/team werden ignoriert). Es werden NUR Abweichungen (false) persistiert; alles-an ⇒ NULL. Antwort = GET-Shape (round-trip).

Sicherheits-Ausnahme — bewusst NICHT per API exponiert

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

Workflow (GEO-Optimizer-Perspektive)

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

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

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

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

📞 Outbound (Callcenter-Leads · Admin-only)

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

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

Ergänzende Endpoints

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

agency

Method URL Beschreibung Credits
GET /agency/approvals Offene Freigabe-Anfragen des Teams auflisten (Inbox) 0
POST /agency/approvals/{}/approve Aktuelle Stufe einer Freigabe-Anfrage genehmigen 0
POST /agency/approvals/{}/reject Lehnt Freigabe-Anfrage ab, optional mit Feedback

brand-voices

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

feedback

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

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

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

Method URL Beschreibung Credits
GET /feedback Eigene Feedback-Meldungen auflisten (paginiert, 25/Seite) 0
POST /feedback Neue Meldung einreichen (message, optional category, optional attachments[] — bis zu 3 Bilder à max. 5 MB, multipart/form-data; optional page_url — max. 2048 Zeichen, nur eine Adresse dieser Anwendung, sonst 422) — löst Bestätigungsmail aus. Eine unbrauchbare Datei ⇒ 422 und es entsteht KEINE Meldung 0
GET /admin/feedback Alle Meldungen auflisten, optional ?status= (Admin) 0
PATCH /admin/feedback/{}/status Status ändern: offen/in_bearbeitung/erledigt (Admin) 0
POST /admin/feedback/{}/confirm-bug Als Bug bestätigen — vergibt einmalig 50 Credits ans meldende Team (Admin, idempotent) 0

engines

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

exports

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

grounding

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

incidents

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

locations

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

module-tours

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

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

optimization

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

search-console

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