Rankion.ai API Reference

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

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

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

Authentifizierung

# 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 prüfen vor dem Job-Dispatch, ob das Team-Guthaben N Credits deckt (sonst 402); gebucht wird im Job bzw. in der Aktion selbst. Bei cache=false Refresh (Explorer) prüft die Anfrage das Guthaben; die 50 Credits bucht der Hintergrund-Job einmal vor dem Anbieter-Abruf (ohne Guthaben kein Abruf, processing_status=failed; scheitert der Abruf endgültig, wird erstattet).


🎯 AI Visibility Tracking

Projekte

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

Freigaben (Cross-Team-Sharing)

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

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

Keywords / Prompts

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

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

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

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

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

Cited Sources

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

Insights

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

Zeitraum der drei Insights-Endpunkte low-hanging-fruit, platform-gap, potential-hero (seit 21.09.2026, dieselben Regeln wie der Zeitraum-Knopf der Oberfläche, SSOT App\Support\AiVisibilityZeitraum):

Brand-Accuracy (Faktencheck)

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

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

Reality Check (AI Visibility Index)

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

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

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

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

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


📝 Articles

CRUD

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

Website-Import (Plan-Hub #32)

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

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

Aktionen

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

Versions

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

Zitat-Prüfung

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

Artikel-Export

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

Freshness

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

Internal Linking

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

Standalone

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

CMS-Integrations

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

🛬 Landingpages

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

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

🔍 SEO Suite

Rank Tracker (Phase 1)

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

Keyword Explorer (Phase 2)

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

Keyword Gap (Phase 3)

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

Site Audit (Phase 4)

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

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

Geändertes Verhalten seit 19.09.2026 (Plan #261). Die lesenden by-domain-Endpunkte liefern Kennzahlen nur noch, wenn das eigene Team für die Domain berechtigt ist: entweder es hat sie selbst abgerufen (Zeile in backlink_lookups, entsteht durch POST …/pull) oder es trackt sie in einem eigenen Projekt. Sonst: 404 — bewusst nicht 403, damit die Antwort nicht verrät, ob die Domain bekannt ist.

Vorher gab es diese Prüfung nicht: die Backlink-Tabellen sind teamübergreifend geteilt (Kostenersparnis), weshalb jeder Token die Kennzahlen jeder Domain lesen konnte, die irgendein anderes Team je gezogen hatte.

Was das für bestehende Aufrufe heißt: Der übliche Ablauf POST …/pull → warten → GET … funktioniert unverändert; der Pull legt die Berechtigung jetzt selbst an. Ein GET auf eine Domain, die nie abgerufen und nicht getrackt wird, antwortet ab sofort mit 404 statt mit Daten. Nicht betroffen: POST …/pull, …/enrich-spam-scores und die beiden link-gap/competitor-overlap-Starter.

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

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

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

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

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

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

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

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


✍️ Content Intelligence

Storylines

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

Interview-Mode (Artikel aus einem Interview)

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

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

Style Profiles

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

Knowledge Base

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

Orphan Scans

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

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

Overview

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

📅 Calendar & Images

Calendar

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

Project Images (legacy per-project listing)

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

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

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

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

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

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

🤖 AI Tools

Agentic Chat — Master Agent als Endpoint

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

Body:

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. Without language, an auto-detected unsupported language (e.g. pl) returns 422 {message, detected_language, supported_languages} — no scan, no credits. Every scan stored in /ai-scanner history. Failed deep scan auto-refunds.
GET /ai-scanner/scans/{id} — Poll an AI-detection scan: {scan_id,scan_type,processing_status,score,details,word_count,language,credits_charged}. Team-scoped.
POST /ai-scanner/humanize dyn Humanize an existing article_id (async 202). Cost ceil(words/1000) × {light:2,standard:4,deep:8} — job-deducted, controller pre-flight gates.

Humanizer

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

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

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

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

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

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. —
GET /community/channels — die Chat-Kanäle mit Lese- und Schreibrecht, Reihenfolge und Zahl der sichtbaren Nachrichten. ?active=1 beschränkt auf offene Kanäle; ohne den Parameter kommen auch stillgelegte, denn die Frage eines auswertenden Systems ist »welche gibt es?«, nicht »welche sind offen?«. Admin-gegatet wie /community/members — ein Chat steht nicht öffentlich im Web. —
GET /community/channels/{}/messages — der Verlauf eines Kanals, seitenweise (per_page, max 200, Vorgabe 50; page). Verborgene Nachrichten erscheinen NICHT — eine Schnittstelle, die mehr zeigt als die Seite, wäre ein Weg an der Moderation vorbei. Als Verfasser kommt das Pseudonym, nie Klarname oder Mailadresse. 404 bei unbekanntem Kanal. Admin-gegatet. —
GET /community/conversations — die Direktnachricht-Gespräche als Zahlen, nicht als Inhalte: Teilnehmer-Pseudonyme, Zahl der Nachrichten, Zeitpunkt der letzten. Der fehlende Nachrichtentext ist der Punkt dieses Endpunkts, nicht eine Lücke: ein Gespräch unter vier Augen wird nicht über eine Schnittstelle ausgeliefert, die ein Skript abrufen kann (Artikel 5 Absatz 1 Buchstabe c DSGVO). Wer einen einzelnen gemeldeten Satz braucht, bekommt ihn über den Meldeweg samt Vorgang und Aktenlage. Seitenweise (per_page, max 200). Admin-gegatet. —

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

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

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

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

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

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

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

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

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

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

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

Community — Stufen-Katalog (OFFEN, ohne Token)

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

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

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


👥 Community Monitor

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

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

📈 Site Monitor

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

🚀 Automation

Bulk Generations

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

Autopilot

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

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

Pipelines

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

🏗 Projects & Settings

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

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

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

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


🎯 Action Center

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

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

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

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

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


🔌 Matomo Integration

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

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

🔌 Google Integrations

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

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

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

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

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


⭐ Review Sources

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

📰 Blog (Admin)

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

💾 Rankion OS

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

💳 Account / Meta

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

Demo-Teams (Vertriebs-Demo-Konsole)

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

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

🧾 Rechnungsdaten

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

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

Workflow (GEO-Optimizer-Perspektive)

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

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

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

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


🤝 Partnerprogramm »Kunden empfehlen«

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

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

Workflow (GEO-Optimizer-Perspektive)

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

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

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

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


👥 Team-Mitglieder

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

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

Workflow (GEO-Optimizer-Perspektive)

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

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

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

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


Agentur-Branding (White-Label-Berichte)

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

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

Workflow (GEO-Optimizer-Perspektive)

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

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

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

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


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

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

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

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

Polling-Pattern:

# 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,
    "strategies": {
      "mobile":  { "performance": 78, "seo": 92, "accessibility": 88, "best_practices": 83, "core_web_vitals": { "lcp": 2500, "cls": 0.01 } },
      "desktop": { "performance": 94, "seo": 92, "accessibility": 88, "best_practices": 83, "core_web_vitals": { "lcp": 900, "cls": 0.0 } }
    }
  },
  "analysis": {
    "user_intent": "1-Satz Search-Intent",
    "persona_fit": 75,
    "trust_score": 72,
    "layout_score": 68,
    "cta_score": 65,
    "problem_solution_clarity": 70,
    "above_the_fold_quality": 60,
    "mobile_friendliness_visual": 80,
    "personas": [
      { "name": "...", "who": "...", "primary_motivation": "...",
        "pain_points": ["..."], "goals": ["..."],
        "fit_score": 75, "fit_reason": "..." }
    ],
    "critical_issues": [
      { "severity": "high|medium|low", "title": "...", "explanation": "...", "evidence": "..." }
    ],
    "improvement_suggestions": [
      { "area": "headline|cta|trust|layout|copy|visuals|forms|navigation|seo|accessibility",
        "priority": "high|medium|low",
        "title": "...", "description": "...", "example": "before/after micro-copy" }
    ],
    "summary": "3-5 Satz Executive-Summary",
    "headline_rewrite": "stärkerer H1-Vorschlag oder null"
  },
  "gpt_image_used": true,
  "credits_used": 75,
  "started_at": "2026-04-27T19:28:58Z",
  "completed_at": "2026-04-27T19:30:22Z"
}

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

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

Use-Cases:


📑 Reports & Cross-Module Correlation

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

Reports

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

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

Polling-Pattern:

# 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/sessions/{session}/abort Stops a running turn (Plan #217). No body. Sets agentic_status='aborting' via the same atomic claim the UI stop button uses, and closes an active plan through AgenticPlanService::abort(). The session is immediately free for a new question — aborting is deliberately not in BUSY_STATUSES. The turn notices at its next checkpoint (before every LLM and tool call) and ends without a further LLM call; credits already consumed stay charged, nothing is consumed after. Response: {data:{session_id, aborted, plan_aborted}}. Returns 409 when no turn is running, 403 for another team's or user's session 0
POST /v1/agentic/feedback Persists 👍/👎 feedback on an assistant message. Body: {message_id: int, direction: 'up'|'down'}. Idempotent upsert keyed by (user_id, message_id) — second click overwrites direction. Returns 404 unknown_msg, 403 not_yours, 422 invalid direction. Response: {ok: true, direction} 0

Plan response shape

{
  "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).

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

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

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

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

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

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

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

Tier-Schwellen

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

Findings-Schema

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

Webhook-Verifikation

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

$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. Abgebucht wird im Job nach erfolgreicher Erzeugung (data_mining_export). Nur das Besitzer-Team des Projekts; Gäste mit Projektfreigabe bekommen 403. 10
GET /v1/data-mining/insights/{project}/section/{section} Einzel-Section-Lazy-Load. {section} ∈ executive_summary, fact_errors, consensus_narratives, consensus_clusters, persona_gaps, battle_cards, outreach_top50, competitor_cooccurrence, early_warnings, source_whitelist, forecast_6w, recommended_actions. 0
GET /v1/data-mining/exports/{project}.pdf Letzten generierten PDF-Export downloaden. 404 falls noch keiner existiert (vorher refresh aufrufen). 0
GET /v1/data-mining/exports/{project}.zip Letztes CSV-Bundle (1 CSV pro Section, gezippt). 404 wenn nicht vorhanden. 0

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

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

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

🧬 Citation Causality (CCE)

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

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

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

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

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

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

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

Live-Dashboards:


⚡ Prefetch (Rankion Turbo)

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

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

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


🔐 Account / Sicherheit

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

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

Sicherheits-Ausnahme — bewusst NICHT per API exponiert

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

Workflow (GEO-Optimizer-Perspektive)

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

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

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

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

📞 Outbound (Callcenter-Leads · Admin-only)

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

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

Ergänzende Endpoints

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

agency

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

brand-voices

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

feedback

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

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

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

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

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

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

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

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

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

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

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

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

engines

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

exports

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

grounding

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

incidents

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

locations

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

module-tours

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

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

optimization

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

search-console

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