Dokumentation · Integrationen
Produkt-API
Über die Produkt-API liest du Websites, Beiträge, Keywords und Ergebnisse deines Workspaces und legst neue Beiträge an. Sie ist der Weg für eigene Werkzeuge, Agenten und Automatisierungen — dieselbe Logik wie in der Oberfläche, nur ohne Browser.
Beta, auf Anfrage freigeschaltet. Diese Seite ist immer sichtbar. Ob die API für deinen Workspace bereits erreichbar ist, siehst du unter Einstellungen → API. Steht dort „API noch nicht freigeschaltet“, antworten alle Endpunkte mit
404— schreib uns, dann schalten wir sie frei.
Basisadresse und Version
Alle Endpunkte liegen unter:
https://app.evnxt.de/api/v1
Die Version steht im Pfad. Solange v1 existiert, ändern sich bestehende Feldnamen nicht; neue Felder können hinzukommen. Ein Client muss unbekannte Felder ignorieren.
Authentifizierung
Jede Anfrage trägt einen API-Schlüssel als Bearer-Token:
Authorization: Bearer evx_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Schlüssel legst du unter Einstellungen → API an (Eigentümer oder Administrator des Workspaces). Der Klartext wird genau einmal angezeigt — danach steht in der Oberfläche nur noch der Anfang zur Wiedererkennung, zum Beispiel evx_live_ab12cd34…. Gespeichert wird bei uns nur ein sha256-Hash; wir können dir einen verlorenen Schlüssel nicht zurückgeben, du legst dann einen neuen an.
Ein Schlüssel gehört zu genau einem Workspace. Es gibt keinen Kontowechsel in der API: Was der Schlüssel nicht sieht, existiert für ihn nicht.
Widerrufene Schlüssel antworten mit 401 und dem Code key_revoked.
Geltungsbereiche (Scopes)
Jeder Schlüssel trägt eine Liste von Geltungsbereichen. Fehlt der für einen Endpunkt nötige, antwortet er mit 403 und dem Code insufficient_scope.
| Scope | erlaubt |
|---|---|
websites:read |
Websites lesen |
articles:read |
Beiträge lesen |
articles:write |
Beiträge anlegen, freigeben, veröffentlichen |
keywords:read |
Keywords lesen |
results:read |
Search-Console-Kennzahlen lesen |
webhooks:manage |
Webhook-Endpunkte verwalten |
Vergib nur, was der jeweilige Zweck braucht. Ein Reporting-Werkzeug braucht kein articles:write.
Rate-Limits
Es gibt zwei Schranken, beide je Schlüssel — nicht je Workspace. Ein aussetzender Integrationsversuch soll den regulären Betrieb desselben Kunden nicht mit ausbremsen.
| Schranke | Voreinstellung | Obergrenze |
|---|---|---|
| Anfragen pro Minute | 60 | 600 |
| Anfragen pro Tag | 10.000 | 100.000 |
Beide stellst du beim Anlegen eines Schlüssels unter Einstellungen → API ein und kannst sie dort später über Limits ändern anpassen.
Warum zwei? Das Minutenlimit begrenzt Lastspitzen, nicht Dauerlast: 60 Anfragen pro Minute wären rund 86.000 am Tag. Ein Skript in einer Endlosschleife bleibt damit dauerhaft unter dem Minutenlimit und läuft trotzdem den ganzen Tag.
Der Tageszähler läuft nach UTC, nicht nach Ortszeit: Sommerzeitwechsel hätten sonst einen 23- und einen 25-Stunden-Tag. Er steigt nur für Anfragen, die tatsächlich bedient werden — wer gerade in die Minutensperre läuft, verbraucht dafür nichts vom Tagesbudget.
Jede Antwort trägt:
| Header | Bedeutung |
|---|---|
X-RateLimit-Limit |
erlaubte Anfragen pro Minute |
X-RateLimit-Remaining |
im laufenden Minutenfenster noch offen |
X-RateLimit-Reset |
Unix-Zeit (Sekunden), zu der das Minutenfenster zurückgesetzt wird |
X-RateLimit-Daily-Limit |
erlaubte Anfragen pro Tag |
X-RateLimit-Daily-Remaining |
heute noch offen |
X-RateLimit-Daily-Reset |
Unix-Zeit (Sekunden) des nächsten Tageswechsels (Mitternacht UTC) |
Retry-After |
nur bei 429: Sekunden bis zum nächsten Versuch |
Bei Überschreitung kommt in beiden Fällen 429 mit dem Code rate_limit_exceeded. Die Meldung nennt, welche Schranke gegriffen hat; unterscheiden lässt es sich auch an Retry-After: beim Minutenlimit sind das höchstens 60 Sekunden, beim Tageslimit die Zeit bis Mitternacht UTC.
HTTP/1.1 429 Too Many Requests
Retry-After: 18143
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-RateLimit-Reset: 1789200060
X-RateLimit-Daily-Limit: 10000
X-RateLimit-Daily-Remaining: 0
X-RateLimit-Daily-Reset: 1789218000
Warte die im Retry-After genannte Zeit ab, statt sofort erneut zu senden.
Jede Antwort trägt außerdem eine X-Request-Id. Nenne sie bei Rückfragen — damit finden wir denselben Aufruf wieder.
Fehlerformat
Jeder Fehler hat dieselbe Form:
{
"error": {
"code": "validation_failed",
"message": "Die Anfrage ist unvollständig oder fehlerhaft.",
"fields": { "title": ["Das Feld title ist erforderlich."] }
}
}
code ist der stabile, maschinenlesbare Teil. message richtet sich an Menschen und darf sich ändern. fields ist immer ein Objekt, bei nicht-Validierungsfehlern ein leeres.
| Code | HTTP | Bedeutung |
|---|---|---|
not_found |
404 | Es gibt diese Ressource nicht — oder sie gehört einem anderen Workspace. Beides ist absichtlich nicht unterscheidbar. |
unauthenticated |
401 | kein oder unbekannter Schlüssel |
key_revoked |
401 | Schlüssel wurde widerrufen |
insufficient_scope |
403 | Scope fehlt |
rate_limit_exceeded |
429 | Minuten- oder Tageslimit erreicht |
validation_failed |
422 | Eingabe fehlerhaft |
quota_exhausted |
402 | Artikelkontingent aufgebraucht |
quality_blocked |
422 | Qualitätsprüfung nicht bestanden |
mode_not_allowed |
409 | Veröffentlichungsmodus lässt diesen Schritt nicht zu |
invalid_state |
409 | Beitrag ist im falschen Status |
cms_error |
502 | Das CMS hat nicht mitgespielt (fields.kind nennt die Art) |
Paginierung
Listen nehmen ?page (ab 1) und ?per_page (1 bis 100, Standard 25). Werte außerhalb werden zurechtgerückt, nicht abgelehnt. Die Antwort trägt:
{ "data": [ … ], "meta": { "page": 1, "per_page": 25, "total": 137, "total_pages": 6 } }
Endpunkte
Websites auflisten
GET /websites · Scope websites:read
curl -H "Authorization: Bearer $EVX_KEY" https://app.evnxt.de/api/v1/websites
{
"data": [
{
"id": 12,
"name": "Laufschuhe-Magazin",
"base_url": "https://laufschuhe.example",
"language": "de",
"market": "DE",
"publish_mode": "approval",
"cms": { "type": "wordpress", "status": "active" }
}
],
"meta": { "total": 1 }
}
publish_mode ist manual, approval oder autopilot und entscheidet, welche Schreibschritte erlaubt sind (siehe unten).
Beiträge einer Website
GET /websites/{id}/articles · Scope articles:read
Optionaler Filter ?status= (zum Beispiel entwurf, angefragt, publiziert), dazu page und per_page.
{
"data": [
{
"id": 481,
"website_id": 12,
"status": "publiziert",
"kind": "redaktionell",
"slug": "laufschuhe-fuer-breite-fuesse",
"title": "Laufschuhe für breite Füße",
"published_at": "2026-09-14T08:12:00+02:00",
"scheduled_at": null,
"remote_url": "https://laufschuhe.example/blog/laufschuhe-fuer-breite-fuesse",
"created_at": "2026-09-12T10:03:11+02:00"
}
],
"meta": { "page": 1, "per_page": 25, "total": 34, "total_pages": 2 }
}
Beitrag im Detail
GET /articles/{id} · Scope articles:read
Enthält zusätzlich body_html, die einzelnen Blöcke, die Aggregate aus Faktencheck und Qualitätsprüfung sowie die Quellen.
{
"data": {
"id": 481,
"title": "Laufschuhe für breite Füße",
"teaser": "Worauf es bei der Leistenbreite ankommt.",
"body_html": "<h2>…</h2><p>…</p>",
"author_name": "Redaktion",
"generation_status": "fertig",
"live_url": "https://laufschuhe.example/blog/laufschuhe-fuer-breite-fuesse",
"blocks": [
{ "position": 1, "type": "text", "heading": "Leisten und Weite", "body_html": "<p>…</p>", "image_url": null, "status": "fertig" }
],
"factcheck": { "version": 2, "score": 91, "coverage": 1.0, "blocks_checked": 7, "blocks_total": 7, "open_critical": 0 },
"quality": { "version": 1, "score": 88, "blocking": [] },
"sources": [
{ "url": "https://example.org/studie", "title": "Laufschuhstudie 2025", "verification": "verified" }
]
}
}
factcheck.coverage unter 1.0 heißt: nicht jeder Textabschnitt wurde geprüft. quality.blocking listet die Befunde, die eine Freigabe verhindern — ist sie nicht leer, wird approve mit quality_blocked abgelehnt.
Beitrag anlegen
POST /websites/{id}/articles · Scope articles:write
| Feld | Pflicht | Beschreibung |
|---|---|---|
title |
ja | Thema oder Arbeitstitel, 5 bis 180 Zeichen. Die KI macht daraus den endgültigen Titel. |
keyword |
nein | Hauptkeyword für die Recherche |
kind |
nein | redaktionell (Standard), produktratgeber oder vergleich |
brief |
nein | Freitext mit Wünschen, bis 2000 Zeichen |
scheduled_at |
nein | gewünschter Veröffentlichungstermin (ISO 8601) |
curl -X POST https://app.evnxt.de/api/v1/websites/12/articles \
-H "Authorization: Bearer $EVX_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"Laufschuhe für breite Füße","keyword":"laufschuhe breite füße"}'
Antwort 201:
{ "data": { "id": 482, "website_id": 12, "status": "entwurf", "title": "Laufschuhe für breite Füße", "slug": "laufschuhe-fuer-breite-fuesse-2", "published_at": null, "remote_url": null } }
Der Beitrag wird nicht sofort fertig. Er startet als Entwurf, die Produktion läuft im Hintergrund (Recherche, Gliederung, Bilder, Text, Prüfung). Den Fortschritt siehst du an generation_status in GET /articles/{id} — oder du abonnierst die Ereignisse article.ready_for_review und article.failed per Webhook.
Das Kontingent wird beim Anlegen reserviert, genau wie in der Oberfläche. Ist keines mehr frei, kommt 402:
{ "error": { "code": "quota_exhausted", "message": "Kontingent erschöpft: Paket Medium: 0 von 8 Credits verfügbar.", "fields": {} } }
Gesponserte Beiträge lassen sich über die API bewusst nicht bestellen — die hängen an Preis und Platzierungslogik und gehören in die Oberfläche.
Beitrag freigeben
POST /articles/{id}/approve · Scope articles:write
Gibt einen Beitrag frei, der zur Prüfung steht (Status angefragt), und veröffentlicht ihn im angebundenen CMS. Erlaubt in den Modi approval und autopilot.
409 mode_not_allowed— die Website steht aufmanual; dort gibt es keine Freigabe-Veröffentlichung.409 invalid_state— der Beitrag steht nicht zur Prüfung.422 quality_blocked— die Qualitätsprüfung ist nicht bestanden.fields.reasonsnennt die Gründe im Klartext.502 cms_error— das CMS hat abgelehnt;fields.kindistauth,conflict,transientoderrejected.
Beitrag veröffentlichen
POST /articles/{id}/publish · Scope articles:write
Für Websites im Modus manual — dort ist Veröffentlichen von Hand der vorgesehene Weg — und als zweiter Anlauf für bereits freigegebene Beiträge, bei denen das CMS beim ersten Mal nicht erreichbar war. Der Vorgang ist idempotent: ein bereits veröffentlichter Beitrag wird nicht ein zweites Mal angelegt.
In den Modi approval und autopilot antwortet der Endpunkt für noch nicht freigegebene Beiträge mit 409 mode_not_allowed — die Erstveröffentlichung läuft dort über approve.
Keywords einer Website
GET /websites/{id}/keywords · Scope keywords:read
Optionaler Filter ?status=candidate|selected|rejected|planned.
{
"data": [
{
"id": 3312,
"website_id": 12,
"keyword": "laufschuhe breite füße",
"language": "de",
"market": "DE",
"intent": "informational",
"status": "selected",
"source": "labs_ideas",
"score": 78,
"search_volume": 1900,
"difficulty": 24,
"data_as_of": "2026-09-01"
}
],
"meta": { "page": 1, "per_page": 25, "total": 184, "total_pages": 8 }
}
search_volume und difficulty sind null, wenn es zu diesem Begriff keine Messung gibt. null heißt unbekannt, nicht „null Suchanfragen“.
Ergebnisse einer Website
GET /websites/{id}/results · Scope results:read
Search-Console-Kennzahlen der letzten 28 Tage, ein Eintrag je Tag.
{
"data": [
{ "date": "2026-09-01", "clicks": 142, "impressions": 5310, "ctr": 0.0267, "position": 12.4 }
],
"meta": { "connected": true, "site_url": "sc-domain:laufschuhe.example", "status": "active", "window_days": 28, "last_sync_at": "2026-09-15T04:22:00+02:00", "total": 28 }
}
Ist keine Property verbunden, kommt trotzdem 200 — mit leerer Liste und Hinweis:
{ "data": [], "meta": { "connected": false, "note": "Für diese Website ist keine Search-Console-Property verbunden — es liegen keine Ergebnisse vor." } }
Das ist Absicht: Die Website gibt es ja, sie hat nur noch keine Ergebnisse. Ein Fehler würde eine Integration abbrechen lassen, wo schlicht nichts zu holen ist.
Webhooks verwalten
GET /webhooks, POST /webhooks, DELETE /webhooks/{id}, POST /webhooks/{id}/test · Scope webhooks:manage
Details, Ereignisliste und Signaturprüfung stehen auf der Seite Webhooks.
Häufige Missverständnisse
„Fremde ID gibt 403." Nein — 404. Wir bestätigen nie, dass eine ID existiert, die dir nicht gehört.
„Nach POST /articles ist der Beitrag fertig." Nein. Er ist bestellt. Die Produktion dauert Minuten bis Stunden.
„approve heißt: im CMS sichtbar." Erst wenn die Antwort kein cms_error enthält und remote_url gefüllt ist. Eine Empfangsbestätigung ist kein Veröffentlichungsnachweis.