Website-Änderungserkennung API#
POST /v2/watch ist eine API zur Erkennung von Website-Änderungen: Du registrierst eine Seite
einmal, und ein droplet-lokaler Scheduler rendert sie in festem Takt in
echtem Headless Chrome neu, vergleicht jede Capture per Diff mit der vorherigen und
benachrichtigt dich (per HMAC-signiertem Webhook, E-Mail oder beidem), wenn sich die Seite
tatsächlich ändert. Es wird den Cron-Job plus Diffing plus Alerting-
Unterbau ersetzen, den du sonst rund um
den perceive-Endpunkt aufbauen müsstest: ein Datensatz statt
Scheduler, Storage-Bucket und Vergleichsskript.
Hier ist der kleinste sinnvolle Aufruf. Sende eine URL und erhalte einen Watcher zurück, der stündlich geplant ist:
curl -X POST https://api.enconvert.com/v2/watch \
-H "X-API-Key: sk_your_private_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/pricing"
}'
Die Antwort ist der vollständige Watcher-Datensatz. Er ist sofort active, und
next_check_at wird auf den nächsten Tick des Pollers gesetzt:
{
"watcher_id": "wat_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7",
"url": "https://example.com/pricing",
"status": "active",
"frequency_minutes": 60,
"diff_mode": "auto",
"track_fields": null,
"webhook_url": null,
"notify_email": true,
"consecutive_errors": 0,
"checks_count": 0,
"last_check_at": null,
"next_check_at": "2026-06-24T18:31:07Z",
"last_change_at": null,
"created_at": "2026-06-24T18:31:07Z",
"updated_at": null
}
Endpoints#
| Methode | Pfad | Zweck |
|---|---|---|
POST |
/v2/watch |
Erstellt einen Watcher für eine URL. Gibt 201 zurück. |
GET |
/v2/watch |
Listet die Watcher dieses Projekts auf, neueste zuerst. |
GET |
/v2/watch/{watcher_id} |
Ruft den vollständigen Datensatz eines Watchers ab. |
GET |
/v2/watch/{watcher_id}/snapshots |
Listet den Prüfverlauf eines Watchers auf, neueste zuerst. |
PATCH |
/v2/watch/{watcher_id} |
Aktualisiert Takt, Diff-Einstellungen oder pausiert/setzt fort. |
DELETE |
/v2/watch/{watcher_id} |
Löscht einen Watcher weich (idempotent). |
Content-Type: application/json bei POST und PATCH.
Authentifizierung#
Authentifiziere dich mit einem privaten Schlüssel im X-API-Key-Header für
Server-zu-Server-Aufrufe. Diesen Weg nutzt jedes Beispiel unten.
X-API-Key: sk_your_private_key
Öffentliche Schlüssel mit einem JWT-Bearer-Token funktionieren ebenfalls, nach demselben Ablauf wie
bei jedem anderen Endpunkt: Erzeuge ein Token mit deinem pk_-Schlüssel und sende es dann
als Authorization: Bearer <token>. Der vollständige Ablauf, einschließlich Domain-Sperre
und Token-Refresh, steht im Authentifizierungs-Leitfaden.
Jeder API-Schlüssel hat eine Allowlist erlaubter Endpunkte. Steht /v2/watch nicht
auf der Liste des Schlüssels, wird die Erstellungsanfrage mit 403 abgelehnt. Sobald ein Schlüssel
Watcher erstellen darf, erreicht er auch die zugehörigen Pro-Watcher-Routen:
GET, PATCH und DELETE auf /v2/watch/{watcher_id} sowie dessen
/snapshots-Seite werden automatisch durchgelassen, sodass du nicht jedes Verb
einzeln freigeben musst.
Wie watch funktioniert#
Dahinter steckt keine externe Queue: kein Google Cloud Tasks, kein
Drittanbieter-Scheduler. Der Zeitplan liegt vollständig in der Datenbankspalte
next_check_at, und ein prozessinterner Poller steuert ihn:
- Erstellen. Du sendest per
POSTeine URL. Die URL wird auf SSRF geprüft (ein privater, Loopback- oder Metadata-Host wird abgelehnt, bevor eine Zeile geschrieben wird), einewat_-ID wird erzeugt, und der Watcher wirdactivegespeichert, wobeinext_check_atauf jetzt gesetzt wird. - Beanspruchen. Ein droplet-lokaler
watch_workerdurchsucht alle 60 Sekunden aktive Zeilen, derennext_check_atüberschritten ist. Er beansprucht sie unterFOR UPDATE SKIP LOCKEDund rückt den Zeitplan jeder einzelnen um ein volles Intervall in derselben Transaktion vor, sodass ein langsames Rendering nie doppelt beansprucht wird und ein Absturz mitten im Rendering lediglich einen Zyklus überspringt. - Rendern. Jeder beanspruchte Watcher wird einmal über das gemeinsame Headless-Chrome-Singleton gerendert, dieselbe Capture-Pipeline, die auch hinter dem perceive-Endpunkt steht. Das Rendering ist credential-frei: Es werden keine Auth-Daten, Cookies oder Header gespeichert, sodass für die wiederkehrende Prüfung nichts Geheimes ruhend gespeichert wird.
- Bewerten und Diffen. Ein Rendering, das unter der Qualitätsschwelle (0.4) liegt oder als blockiert markiert ist, wird als reine Audit-Prüfung erfasst, ohne Content-Hash, sodass es nie zur Diff-Baseline wird und nie eine Benachrichtigung auslöst. Ein gutes Rendering wird in eine Capture umgewandelt (Haupttext plus extrahierte Struktur), gegen die letzte gute Capture per Diff verglichen, und das Ergebnis wird in eine Snapshot-Zeile geschrieben.
- Benachrichtigen. Meldet der Diff eine Änderung, lösen der HMAC-signierte
Webhook (falls gesetzt) und die E-Mail an den Owner (falls
notify_emailaktiv ist) gleichzeitig aus, nach bestem Bemühen (best-effort). - Neu planen. Der Worker schreibt das nächste
next_check_at. Drei aufeinanderfolgende Render-Fehlschläge pausieren den Watcher und benachrichtigen den Owner per E-Mail, statt neu zu planen.
Da der Zeitplan eine Datenbankspalte ist, braucht Downtime keine Recovery-Logik: Der erste Tick nach dem Start räumt alles Überfällige auf.
Anfrageparameter#
Erstellen (POST /v2/watch)#
| Parameter | Typ | Standard | Beschreibung |
|---|---|---|---|
url |
string |
-- | Die zu überwachende Seite. Muss mit http:// oder https:// beginnen. Max. 2,048 Zeichen. Erforderlich. |
frequency_minutes |
integer |
60 |
Minuten zwischen den Prüfungen. Harte Stundenuntergrenze: Minimum 60, Maximum 43200 (30 Tage). |
diff_mode |
string |
"auto" |
Welche Diff-Strategie angewendet wird. auto, text, structured, tables oder metadata. Siehe Diff-Modi. |
track_fields |
object |
null |
Optionale Teilmenge von Feldern/Selektoren, um einzugrenzen, was als Änderung zählt. Siehe Eine Teilmenge von Feldern verfolgen. |
webhook_url |
string |
null |
Optionales Ziel für Änderungsbenachrichtigungen. HMAC-signiert, unmittelbar vor jeder Zustellung auf SSRF geprüft. Max. 2,048 Zeichen; muss http(s) sein. |
notify_email |
boolean |
true |
Benachrichtigt den Projekt-Owner per E-Mail bei einer erkannten Änderung und bei Auto-Pause. |
Das Anfrage-Schema ist strikt (extra="forbid"): Ein unbekanntes Feld wird
mit 422 abgelehnt. Es gibt hier bewusst keine auth-, cookies- oder
headers-Oberfläche, denn Watcher bleiben credential-frei, dieselbe Haltung wie
der ingest-Endpunkt.
Aktualisieren (PATCH /v2/watch/{watcher_id})#
Jedes Feld ist optional; nur die im Body vorhandenen Schlüssel werden angewendet.
| Parameter | Typ | Beschreibung |
|---|---|---|
frequency_minutes |
integer |
Neuer Takt. Dieselben Grenzen von 60 bis 43200 wie beim Erstellen. |
diff_mode |
string |
Wechselt die Diff-Strategie. |
track_fields |
object |
Ersetzt die Teilmenge der verfolgten Felder. |
webhook_url |
string |
Setzt einen neuen Webhook. Ein leerer String ist das explizite Signal zum Löschen und speichert NULL. |
notify_email |
boolean |
Schaltet die Owner-E-Mail um. |
status |
string |
active oder paused. Fortsetzen aktiviert den Zeitplan neu (next_check_at wird auf den nächsten Tick gesetzt); Pausieren leert ihn, sodass der Poller die Zeile nicht mehr beansprucht. |
Ein leerer Body ({}) wird mit 422 abgelehnt, statt stillschweigend zu einem No-op zu werden.
Beachte, dass status hier nur active oder paused akzeptiert. Der finale
deleted-Zustand wird ausschließlich über DELETE erreicht, nie über PATCH. Das Fortsetzen eines
pausierten Watchers zählt wie das Hinzufügen eines aktiven Monitors und prüft daher erneut gegen dieselbe
max_watchers-Grenze wie beim Erstellen und kann 402 zurückgeben.
Diff-Modi#
diff_mode wählt aus, welche von vier inhaltstyp-bewussten Strategien die Engine
ausführt. auto führt alle vier aus und führt ihre Ergebnisse zusammen; die benannten Modi
beschränken den Diff auf eine einzelne Strategie.
diff_mode |
Strategie | Was erkannt wird |
|---|---|---|
auto (Standard) |
Alle vier unten | Jede Art von Änderung in einem Durchgang. |
text |
SequenceMatcher-Ratio des Haupttexts |
Textkörper hat sich geändert; wird markiert, wenn die Ähnlichkeit unter 0.98 fällt, sodass ein umgestelltes Wort oder eine Whitespace-Änderung den Watcher nicht flattern lässt. Enthält einen Unified Diff, begrenzt auf 100 Zeilen. |
structured |
Keyed-List-Matching | Hinzugefügte / entfernte / pro Feld geänderte Einträge bei Links (abgeglichen über href) und JSON-LD-Blöcken (abgeglichen über @type + name). Reihenfolge spielt keine Rolle. |
tables |
Kontext-Überschriften-Matching | Tabellen werden über Beschriftung/Überschrift abgeglichen; meldet Änderungen der Zeilenanzahl, hinzugefügte/entfernte Tabellen und Inhaltsänderungen bei gleicher Zeilenanzahl (begrenzt auf 100 Zeilen Kontext). |
metadata |
Schlüssel-für-Schlüssel-Dict-Vergleich | Hinzugefügte, entfernte und geänderte Seiten-Metadatenfelder. |
Unabhängig vom gewählten Modus ist das similarity des Snapshots immer die
gesamte Ratio der kompletten Capture (0.0 bis 1.0). Bei einem eingeschränkten Modus kann das bedeuten,
dass similarity niedrig ausfällt, während has_changes false ist, weil ein Abschnitt, den du
nicht diffst, sich verändert hat, während sich in deiner gewählten Strategie nichts geändert hat.
Eine Teilmenge von Feldern verfolgen#
track_fields beschränkt den Diff auf Änderungen, deren Abschnitt, Feld oder Schlüssel
mit einem verfolgten Begriff übereinstimmt. Es akzeptiert ein Objekt, dessen Schlüssel sowie alle
Listenwerte zu den verfolgten Begriffen werden. So verfolgt {"metadata": ["title"]} sowohl
den Abschnitt metadata als auch das Feld title. Der Abgleich erfolgt Token-genau, nicht als
Teilstring: Ein Begriff price trifft auf offers.price, aber nicht auf
priceCurrency.
Antwort#
POST, GET /v2/watch/{watcher_id}, PATCH und DELETE geben alle
dasselbe Watcher-Objekt zurück.
| Feld | Typ | Beschreibung |
|---|---|---|
watcher_id |
string |
Opake ID (wat_...). Verwende sie auf den Pro-Watcher-Routen. |
url |
string |
Die überwachte URL. |
status |
string |
active, paused oder deleted. |
frequency_minutes |
integer |
Aktueller Takt, nach Anwendung der Stundenuntergrenze. |
diff_mode |
string |
Die aktive Diff-Strategie. |
track_fields |
object |
Die Teilmenge der verfolgten Felder, oder null. |
webhook_url |
string |
Der Änderungs-Webhook, oder null. |
notify_email |
boolean |
Ob die Owner-E-Mail aktiv ist. |
consecutive_errors |
integer |
Aufeinanderfolgende Render-Fehlschläge. Wird bei einer erfolgreichen Prüfung auf 0 zurückgesetzt; bei 3 pausiert der Watcher automatisch. |
checks_count |
integer |
Gesamtzahl durchgeführter Prüfungen, erfolgreich oder fehlgeschlagen. |
last_check_at |
string |
UTC-Zeitstempel der letzten Prüfung, oder null. |
next_check_at |
string |
UTC-Zeitstempel der nächsten geplanten Prüfung. null bei pausiert oder gelöscht. |
last_change_at |
string |
UTC-Zeitstempel der zuletzt erkannten Änderung, oder null. |
created_at |
string |
Wann der Watcher erstellt wurde. |
updated_at |
string |
Letzte Änderung, oder null, falls nie aktualisiert. |
GET /v2/watch gibt pro Zeile ein kompaktes WatcherSummary zurück (es lässt
diff_mode, track_fields, webhook_url, notify_email und
updated_at weg), eingebettet in eine Page-Envelope:
| Feld | Typ | Beschreibung |
|---|---|---|
watchers |
array |
Die Seite der Summaries, neueste zuerst. |
skip |
integer |
Der von dir angeforderte Offset. |
limit |
integer |
Die aktuell geltende Seitengröße. |
has_more |
boolean |
true, wenn über diese Seite hinaus weitere Watcher existieren. |
Snapshot-Verlauf#
GET /v2/watch/{watcher_id}/snapshots gibt die Prüf-Timeline des Watchers zurück,
neueste zuerst. Jeder Eintrag enthält das Diff-Ergebnis. Der eigentliche
Snapshot-Capture-Inhalt liegt im Storage und wird hier nicht zurückgegeben.
| Feld | Typ | Beschreibung |
|---|---|---|
checked_at |
string |
UTC-Zeitstempel der Prüfung. |
has_changes |
boolean |
Ob diese Prüfung eine Änderung erkannt hat. |
similarity |
number |
Gesamte Ratio der kompletten Capture, 0.0 bis 1.0, oder null. |
render_quality |
number |
Render-Qualitätswert für die Prüfung, oder null. |
change_count |
integer |
Anzahl der strukturierten Änderungsdatensätze. |
changes |
array |
Der strukturierte Diff: ein Datensatz pro Änderung, jeweils mit section, kind (added/removed/modified), key, field, before, after. |
Wichtiger Hinweis. Die Werte
beforeundafterinnerhalb vonchangessind roher Seiteninhalt (Linktext, Metadaten, JSON-LD-Werte), keine bereinigte Ausgabe. Wenn du sie in HTML renderst (ein Dashboard, eine E-Mail), musst du sie selbst escapen. Lange String-Werte sind bereits auf 2,000 Zeichen gekürzt, und ein einzelner Diff ist auf 500 Änderungsdatensätze begrenzt.
Lebenszyklus und Zeitplanung#
Ein Watcher durchläuft drei Zustände:
active: auf dem Zeitplan des Pollers.next_check_atist gesetzt.paused: nicht mehr im Zeitplan (next_check_atistnull). Wird entweder überPATCH {"status": "paused"}erreicht oder automatisch nach drei aufeinanderfolgenden Render-Fehlschlägen.deleted: finaler Tombstone, nur überDELETEerreichbar. Die Zeile bleibt erhalten (sodass der Prüfverlauf erhalten bleibt), erscheint aber nie in Listen und liefert404auf den Pro-Watcher-Routen.
Die Stundenuntergrenze wird an zwei Stellen durchgesetzt: beim Erstellen/Aktualisieren
und erneut vom Scheduler, wenn er next_check_at vorrückt. So kann selbst eine Zeile, deren
Takt direkt in der Datenbank bearbeitet wurde, die Untergrenze nie unterschreiten.
Auto-Pause#
Nach drei aufeinanderfolgenden Render-Fehlschlägen wird der Watcher auf paused gesetzt,
sein Zeitplan wird geleert, und falls notify_email aktiv ist, erhält der Owner eine
E-Mail zum pausierten Watcher. Eine einzelne erfolgreiche Prüfung setzt consecutive_errors
auf 0 zurück. Um einen pausierten Watcher neu zu starten, setze ihn per PATCH zurück auf active,
wodurch next_check_at für den nächsten Tick neu aktiviert wird.
Löschen#
DELETE /v2/watch/{watcher_id} ist ein Soft-Delete: Es setzt status auf
deleted, leert next_check_at und gibt den tombstoned Datensatz mit
200 zurück. Es ist idempotent: Das Löschen eines bereits gelöschten Watchers gibt
denselben Datensatz unverändert zurück. Gelöschte Watcher zählen sofort nicht mehr gegen dein
max_watchers-Kontingent (nur active-Watcher zählen).
Codebeispiele#
curl: Erstellen mit Standardwerten#
curl -X POST https://api.enconvert.com/v2/watch \
-H "X-API-Key: sk_your_private_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/pricing"
}'
curl: alle 6 Stunden, Webhook plus Tabellen-Tracking#
curl -X POST https://api.enconvert.com/v2/watch \
-H "X-API-Key: sk_your_private_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/pricing",
"frequency_minutes": 360,
"diff_mode": "tables",
"webhook_url": "https://hooks.example.com/enconvert",
"notify_email": false
}'
curl: Pausieren, dann fortsetzen#
curl -X PATCH \
https://api.enconvert.com/v2/watch/wat_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7 \
-H "X-API-Key: sk_your_private_key" \
-H "Content-Type: application/json" \
-d '{"status": "paused"}'
curl -X PATCH \
https://api.enconvert.com/v2/watch/wat_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7 \
-H "X-API-Key: sk_your_private_key" \
-H "Content-Type: application/json" \
-d '{"status": "active"}'
Python#
import requests
BASE = "https://api.enconvert.com"
HEADERS = {"X-API-Key": "sk_your_private_key"}
# Create a watcher.
created = requests.post(
f"{BASE}/v2/watch",
headers=HEADERS,
json={
"url": "https://example.com/pricing",
"frequency_minutes": 120,
"diff_mode": "auto",
},
)
created.raise_for_status()
watcher = created.json()
watcher_id = watcher["watcher_id"]
# Later: pull the check history and read the diff verdicts.
snaps = requests.get(
f"{BASE}/v2/watch/{watcher_id}/snapshots",
headers=HEADERS,
)
snaps.raise_for_status()
for snap in snaps.json()["snapshots"]:
if snap["has_changes"]:
print(snap["checked_at"], snap["change_count"], snap["similarity"])
Node.js#
const BASE = "https://api.enconvert.com";
const HEADERS = {
"Content-Type": "application/json",
"X-API-Key": "sk_your_private_key"
};
// Create a watcher.
const created = await fetch(`${BASE}/v2/watch`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
url: "https://example.com/pricing",
frequency_minutes: 120,
diff_mode: "auto"
})
});
const watcher = await created.json();
// List this project's watchers, newest first.
const list = await fetch(`${BASE}/v2/watch?limit=20`, {
headers: { "X-API-Key": "sk_your_private_key" }
}).then(r => r.json());
console.log(watcher.watcher_id, list.has_more);
Fehlerantworten#
| Status | Bedingung |
|---|---|
400 Bad Request |
Die URL löst zu einer privaten, Loopback-, Link-Local- oder Metadata-Adresse auf (SSRF-Schutz beim Erstellen). |
401 Unauthorized |
Fehlender oder ungültiger API-Schlüssel / JWT-Token. |
402 Payment Required |
Watch ist auf deinem Plan nicht aktiviert, oder deine Anzahl aktiver Watcher hat max_watchers erreicht (wird auch beim Fortsetzen von pausiert→aktiv durchgesetzt). |
403 Forbidden |
/v2/watch gehört nicht zu den erlaubten Endpunkten des API-Schlüssels. |
404 Not Found |
Unbekannte watcher_id, eine, die einem anderen Projekt gehört, oder eine bereits soft-gelöschte. |
422 Unprocessable Entity |
frequency_minutes unter 60 oder über 43200, ein leerer PATCH-Body ({}), ein ungültiger Enum-Wert in diff_mode oder status, eine url/webhook_url, die nicht http(s) ist, oder ein beliebiges unbekanntes Feld. |
500 Internal Server Error |
Der Watcher konnte nicht erstellt werden. Erneut versuchen. |
Die vollständige Statuscode-Referenz findest du im Fehlercode-Leitfaden.
Limits#
| Limit | Wert |
|---|---|
| URL-Länge | 2,048 Zeichen |
Aktive Watcher (max_watchers) |
20 / 100 / 500 auf Indie / Studio / Production; Watch ist im Founding-Plan nicht verfügbar |
| Ops pro Prüfung | 0 (Watcher schöpfen nie aus dem monatlichen Ops-Kontingent) |
webhook_url-Länge |
2,048 Zeichen |
frequency_minutes |
60 bis 43,200 (1 Stunde bis 30 Tage) |
| Stundenuntergrenze | 60 Minuten, durchgesetzt beim Erstellen, Aktualisieren und bei der Zeitplanung |
| Poll-Intervall | 60 Sekunden (eine Prüfung löst innerhalb einer Minute nach ihrer geplanten Zeit aus) |
| Aufeinanderfolgende Fehler vor Auto-Pause | 3 |
| Render-Qualitätsuntergrenze (kein Diff darunter) | 0.4 |
| Ähnlichkeitsschwelle für Textänderungen | 0.98 |
| Unified-Text-Diff | begrenzt auf 100 Zeilen |
| Änderungsdatensätze pro Diff | begrenzt auf 500 |
| String-Wert pro Änderung | gekürzt auf 2,000 Zeichen |
| Erfasster, per Diff verglichener Textkörper | begrenzt auf 200,000 Zeichen |
| Seitengröße Liste/Snapshot | Standard 20, max. 100 |
Häufig gestellte Fragen#
Wie richte ich Webhooks für die Überwachung von Website-Änderungen ein?#
Übergib webhook_url beim Erstellen des Watchers mit POST /v2/watch, oder füge es später über PATCH /v2/watch/{watcher_id} hinzu. Wenn eine Prüfung eine Änderung erkennt, sendet EnConvert einen HMAC-signierten POST an diese URL; das Ziel wird unmittelbar vor jeder Zustellung auf SSRF geprüft, und ein leerer String bei PATCH löscht ihn.
Wie oft kann die API eine Seite auf Änderungen prüfen?#
frequency_minutes legt den Takt fest, von 60 (die harte Stundenuntergrenze) bis 43200 (30 Tage). Der Poller durchsucht alle 60 Sekunden, sodass eine Prüfung innerhalb einer Minute nach ihrer geplanten Zeit auslöst.
Warum hat sich mein Watcher selbst pausiert?#
Drei aufeinanderfolgende Render-Fehlschläge pausieren einen Watcher automatisch, leeren seinen Zeitplan und benachrichtigen den Owner per E-Mail, falls notify_email aktiv ist. Setze ihn per PATCH mit {"status": "active"} zurück, um next_check_at neu zu aktivieren; eine einzelne erfolgreiche Prüfung setzt consecutive_errors auf 0 zurück.
Kann ich nur einen Teil einer Seite überwachen, z. B. einen Preis oder eine Tabelle?#
Ja. track_fields beschränkt den Diff auf Änderungen, deren Abschnitt, Feld oder Schlüssel mit einem verfolgten Begriff übereinstimmt (Token-genauer Abgleich, sodass price auf offers.price, aber nicht auf priceCurrency trifft), und diff_mode kann die Erkennung auf eine einzelne Strategie wie tables, structured, text oder metadata beschränken.