Dokumentation · Integrationen
Webhooks
Ein Webhook ist eine Adresse von dir, an die wir schicken, was in deinem Workspace passiert: ein Beitrag steht zur Prüfung, ein Beitrag ist erschienen, eine Keyword-Recherche ist durch. Du musst nicht pollen.
Beta, auf Anfrage freigeschaltet. Diese Seite ist immer sichtbar. Endpunkte anlegen kannst du unter Einstellungen → API, sobald die Produkt-API für deinen Workspace frei ist.
Endpunkt anlegen
Unter Einstellungen → API oder über die API:
curl -X POST https://app.evnxt.de/api/v1/webhooks \
-H "Authorization: Bearer $EVX_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/evnxt","events":["article.published","article.failed"]}'
Die Adresse muss https sein und öffentlich erreichbar. Adressen im privaten Netz (localhost, 10.*, 192.168.*, 169.254.*, …) werden abgelehnt — sonst wäre ein Webhook der bequemste Weg, unseren Server gegen fremde interne Dienste laufen zu lassen.
Die Antwort enthält das Secret genau einmal. Es ist der Schlüssel der Signatur; speichere es sicher, es wird nie wieder angezeigt.
Mit POST /webhooks/{id}/test schickst du ein ping-Ereignis an die Adresse. Das Ergebnis erscheint im Zustellprotokoll unter Einstellungen → API.
Ereignisse
| Ereignis | wann |
|---|---|
article.created |
Ein Beitrag wurde angelegt; die Produktion beginnt. |
article.ready_for_review |
Der Beitrag ist fertig produziert und liegt zur Freigabe. |
article.approved |
Der Beitrag wurde freigegeben. |
article.published |
Der Beitrag ist veröffentlicht (bei angebundenem CMS mit remote_url). |
article.updated |
Eine überarbeitete Fassung wurde übernommen. |
article.failed |
Die Veröffentlichung ist gescheitert; kind nennt die Art. |
improvement.proposed |
Ein Verbesserungsvorschlag liegt im Posteingang. |
keyword_research.completed |
Eine Keyword-Recherche ist durchgelaufen. |
cms.connection_error |
Die tägliche Prüfung der CMS-Verbindung ist fehlgeschlagen. |
ping |
Nur auf Anforderung über den Test-Button; nicht abonnierbar. |
Wir senden ausschließlich Ereignisse, die dieser Endpunkt abonniert hat. Gibt es für ein Ereignis keinen aktiven Endpunkt, entsteht gar kein Zustellversuch.
Payload
Der Umschlag ist bei jedem Ereignis gleich; nur data unterscheidet sich.
{
"event": "article.published",
"created_at": "2026-09-15T09:41:02+02:00",
"data": {
"article_id": 481,
"website_id": 12,
"title": "Laufschuhe für breite Füße",
"slug": "laufschuhe-fuer-breite-fuesse",
"status": "publiziert",
"remote_url": "https://laufschuhe.example/blog/laufschuhe-fuer-breite-fuesse",
"published_at": "2026-09-15T09:41:00+02:00"
}
}
Neue Felder können jederzeit hinzukommen. Ein Empfänger muss unbekannte Felder ignorieren.
Header
| Header | Inhalt |
|---|---|
X-Evnxt-Event |
Name des Ereignisses |
X-Evnxt-Event-Id |
UUID des Ereignisses — bei jeder Wiederholung dieselbe |
X-Evnxt-Timestamp |
Unix-Zeit des Sendeversuchs |
X-Evnxt-Signature |
sha256=<HMAC> |
Die Ereignis-ID ist dein Werkzeug gegen Doppelverarbeitung: Merke dir verarbeitete IDs und verwirf Wiederholungen. Eine Wiederholung trägt denselben Inhalt wie der erste Versuch — wir laden nichts nach.
Signatur prüfen
Signiert wird die Zeichenkette "<timestamp>.<body>" mit deinem Secret, per HMAC-SHA256.
PHP
$secret = getenv('EVNXT_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_EVNXT_TIMESTAMP'] ?? '';
$signatur = $_SERVER['HTTP_X_EVNXT_SIGNATURE'] ?? '';
$erwartet = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
// hash_equals statt ==: ein einfacher Vergleich verrät über die Laufzeit,
// wie viele Zeichen gestimmt haben.
if (! hash_equals($erwartet, $signatur)) {
http_response_code(401);
exit;
}
// Alte Aufrufe verwerfen (Wiedereinspielung): mehr als fünf Minuten alt.
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit;
}
$ereignis = json_decode($body, true);
http_response_code(200);
Node.js
import crypto from 'node:crypto';
export function pruefe(rohKoerper, header, secret) {
const zeit = header['x-evnxt-timestamp'] ?? '';
const erwartet =
'sha256=' + crypto.createHmac('sha256', secret).update(`${zeit}.${rohKoerper}`).digest('hex');
const gesendet = header['x-evnxt-signature'] ?? '';
const a = Buffer.from(erwartet);
const b = Buffer.from(gesendet);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
return Math.abs(Date.now() / 1000 - Number(zeit)) <= 300;
}
Wichtig: Signiert wird der rohe Körper, Zeichen für Zeichen. Wer erst JSON parst und neu serialisiert, bekommt eine andere Zeichenkette und damit eine andere Signatur.
Antwort, Wiederholungen und Abschaltung
Antworte mit einem beliebigen 2xx, sobald du die Nachricht angenommen hast. Verarbeite sie danach — eine langsame Antwort läuft nach 10 Sekunden in einen Zeitfehler und gilt als Fehlschlag.
Ist die Zustellung nicht erfolgreich, wiederholen wir sie:
| Versuch | Abstand zum vorherigen |
|---|---|
| 2 | 1 Minute |
| 3 | 5 Minuten |
| 4 | 30 Minuten |
| 5 | 2 Stunden |
| 6 | 12 Stunden |
Danach gilt die Zustellung als endgültig gescheitert (Status dead). Jede Wiederholung trägt dieselbe X-Evnxt-Event-Id und denselben Inhalt; nur Zeitstempel und Signatur sind neu.
Zehn Fehlschläge in Folge schalten den Endpunkt ab. Der Eigentümer des Workspaces bekommt eine Mail, offene Zustellungen laufen nicht weiter. Sobald deine Adresse wieder erreichbar ist, legst du den Endpunkt neu an. Eine erfolgreiche Zustellung setzt den Zähler zurück.
Das Zustellprotokoll der letzten 50 Versuche steht unter Einstellungen → API: Ereignis, Status, Anzahl Versuche, Antwortcode und der Zeitpunkt des nächsten Versuchs.
Was eine Empfangsbestätigung nicht ist
Dein 2xx sagt: „angekommen“. Es sagt nicht, dass auf deiner Seite etwas veröffentlicht wurde. Umgekehrt gilt dasselbe: article.published heißt, dass wir den Beitrag erfolgreich an das angebundene CMS übergeben haben — ob er dort öffentlich sichtbar ist, entscheidet dein CMS.