API de détection de changements de site#

Bêta privée. Watch est appelable dès aujourd'hui avec votre clé d'API habituelle sur tout forfait payant, et les watchers ne coûtent aucune op ; le forfait gratuit Founding ne peut pas en créer. Ce n'est ni annoncé ni disponible de façon générale : les formes de requête et de réponse peuvent changer sans préavis, et il n'y a aucun engagement de stabilité ni de support, donc ne construisez rien de critique dessus pour l'instant. La feuille de route est sur Bientôt disponible, et chaque publication est annoncée dans le changelog.

POST /v2/watch est une API de détection de changements de site : vous enregistrez une page une fois, et un planificateur local au droplet la re-génère à une cadence fixe dans un véritable Chrome headless, compare chaque capture à la précédente, et vous notifie par webhook signé HMAC, par email, ou par les deux, quand la page change réellement. Cela remplacera le cron job, la logique de diff et l'alerting que vous devriez sinon mettre en place autour de l'endpoint perceive : un seul enregistrement au lieu d'un planificateur, d'un bucket de stockage et d'un script de comparaison.

Voici l'appel minimal utile. Envoyez une URL et récupérez un watcher programmé pour vérifier toutes les heures :

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"
  }'

La réponse est l'enregistrement complet du watcher. Il est active immédiatement, et next_check_at est réglé sur le prochain passage du poller :

{
    "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#

Méthode Chemin Objectif
POST /v2/watch Crée un watcher pour une URL. Renvoie 201.
GET /v2/watch Liste les watchers de ce projet, du plus récent au plus ancien.
GET /v2/watch/{watcher_id} Récupère l'enregistrement complet d'un watcher.
GET /v2/watch/{watcher_id}/snapshots Liste l'historique des vérifications d'un watcher, du plus récent au plus ancien.
PATCH /v2/watch/{watcher_id} Met à jour la cadence, les paramètres de diff, ou met en pause/reprend.
DELETE /v2/watch/{watcher_id} Supprime un watcher en soft-delete (idempotent).

Content-Type : application/json sur POST et PATCH.


Authentification#

Authentifiez-vous avec une clé privée dans l'en-tête X-API-Key pour les appels serveur à serveur. C'est la méthode utilisée par tous les exemples ci-dessous.

X-API-Key: sk_your_private_key

Les clés publiques avec un token bearer JWT fonctionnent aussi, en suivant le même flux que pour tous les autres endpoints : générez un token avec votre clé pk_, puis envoyez-le en tant que Authorization: Bearer <token>. Le flux complet, incluant le verrouillage de domaine et le renouvellement de token, se trouve dans le guide d'authentification.

Chaque clé API porte une liste blanche d'endpoints autorisés. Si /v2/watch ne figure pas dans la liste de la clé, la requête de création est rejetée avec 403. Une fois qu'une clé peut créer des watchers, elle peut aussi accéder aux routes par watcher qu'elle possède : GET, PATCH et DELETE sur /v2/watch/{watcher_id} et sa page /snapshots sont autorisées automatiquement, donc vous n'avez pas besoin d'ajouter chaque verbe séparément à la liste blanche.


Comment fonctionne watch#

Il n'y a aucune file d'attente externe derrière tout ça : pas de Google Cloud Tasks, pas de scheduler tiers. Le planning vit entièrement dans la colonne de base de données next_check_at, et un poller in-process le pilote :

  1. Création. Vous faites un POST d'une URL. L'URL est filtrée contre le SSRF (un hôte privé, loopback, ou de métadonnées est rejeté avant qu'aucune ligne ne soit écrite), un ID wat_ est généré, et le watcher est stocké en active avec next_check_at réglé sur maintenant.
  2. Prise en charge. Un watch_worker local au droplet scanne toutes les 60 secondes les lignes actives dont next_check_at est dépassé. Il les prend en charge sous FOR UPDATE SKIP LOCKED et avance le planning de chacune d'un intervalle complet dans la même transaction. Ainsi un rendu lent n'est jamais pris en charge deux fois, et un crash en cours de rendu se contente de sauter un cycle.
  3. Rendu. Chaque watcher pris en charge est rendu une fois via le singleton Chrome headless partagé, le même pipeline de capture que celui derrière l'endpoint perceive. Le rendu est sans identifiants : aucune authentification, cookie, ou en-tête n'est stocké, donc rien de secret ne persiste au repos pour la vérification récurrente.
  4. Score et diff. Un rendu dont le score est en dessous du plancher de qualité (0.4) ou signalé comme bloqué est enregistré comme une vérification à titre d'audit uniquement, sans hash de contenu, donc il ne devient jamais une base de comparaison et ne déclenche jamais de notification. Un bon rendu est transformé en capture (texte du contenu principal plus structure extraite), comparé à la dernière bonne capture, et le verdict est écrit dans une ligne de snapshot.
  5. Notification. Quand le diff signale un changement, le webhook signé HMAC (s'il est configuré) et l'email du propriétaire (si notify_email est activé) se déclenchent en parallèle, en best-effort.
  6. Replanification. Le worker écrit le prochain next_check_at. Trois échecs de rendu consécutifs mettent le watcher en pause et envoient un email au propriétaire au lieu de replanifier.

Comme le planning est une colonne de base de données, aucune logique de reprise n'est nécessaire après une interruption : le premier passage après le démarrage rattrape tout ce qui est en retard.


Paramètres de requête#

Création (POST /v2/watch)#

Paramètre Type Valeur par défaut Description
url string aucun La page à surveiller. Doit commencer par http:// ou https://. Max 2,048 caractères. Obligatoire.
frequency_minutes integer 60 Minutes entre les vérifications. Plancher horaire strict : minimum 60, maximum 43200 (30 jours).
diff_mode string "auto" La stratégie de diff à appliquer. auto, text, structured, tables, ou metadata. Voir Modes de diff.
track_fields object null Sous-ensemble optionnel de champs/sélecteurs pour restreindre ce qui compte comme un changement. Voir Suivre un sous-ensemble de champs.
webhook_url string null Cible optionnelle de notification de changement. Signée HMAC, filtrée contre le SSRF immédiatement avant chaque envoi. Max 2,048 caractères ; doit être en http(s).
notify_email boolean true Envoie un email au propriétaire du projet en cas de changement détecté et en cas de mise en pause automatique.

Le schéma de la requête est strict (extra="forbid") : un champ inconnu est rejeté avec 422. Il n'y a délibérément aucune surface auth, cookies, ou headers ici, car les watchers restent sans identifiants, la même posture que l'endpoint ingest.

Mise à jour (PATCH /v2/watch/{watcher_id})#

Chaque champ est optionnel ; seules les clés présentes dans le corps sont appliquées.

Paramètre Type Description
frequency_minutes integer Nouvelle cadence. Mêmes bornes qu'à la création : de 60 à 43200.
diff_mode string Change la stratégie de diff.
track_fields object Remplace le sous-ensemble de champs suivis.
webhook_url string Définit un nouveau webhook. Une chaîne vide est le signal explicite pour l'effacer et stocke NULL.
notify_email boolean Active/désactive l'email au propriétaire.
status string active ou paused. Reprendre réarme le planning (next_check_at est réglé sur le prochain passage) ; mettre en pause l'efface pour que le poller arrête de prendre en charge la ligne.

Un corps vide ({}) est rejeté avec 422 plutôt que d'être silencieusement ignoré. Notez que status n'accepte que active ou paused ici. L'état terminal deleted est atteint via DELETE, jamais via PATCH. Reprendre un watcher en pause compte comme l'ajout d'un moniteur actif, donc cela revérifie le même plafond max_watchers qu'à la création et peut renvoyer 402.


Modes de diff#

diff_mode détermine laquelle des quatre stratégies sensibles au type de contenu le moteur exécute. auto exécute les quatre et fusionne leurs résultats ; les modes nommés restreignent le diff à une seule stratégie.

diff_mode Stratégie Ce qu'il signale
auto (par défaut) Les quatre ci-dessous Tout type de changement en une seule passe.
text Ratio SequenceMatcher du contenu principal Le texte du corps a changé, signalé quand la similarité descend sous 0.98, de sorte qu'un mot réordonné ou une modification d'espacement ne déclenche pas le watcher à tort. Comporte un diff unifié plafonné à 100 lignes.
structured Correspondance de listes par clé Éléments ajoutés / supprimés / modifiés champ par champ parmi les liens (mis en correspondance par href) et les blocs JSON-LD (mis en correspondance par @type + name). Insensible à l'ordre.
tables Correspondance par titre de contexte Tables mises en correspondance par légende/titre ; signale les changements de nombre de lignes, les tables ajoutées/supprimées, et les modifications de contenu à nombre de lignes égal (limité à 100 lignes de contexte).
metadata Comparaison de dictionnaire clé par clé Champs de métadonnées de page ajoutés, supprimés et modifiés.

Quel que soit le mode choisi, le similarity du snapshot est toujours le ratio global sur l'ensemble de la capture (de 0.0 à 1.0). Sous un mode restreint, cela signifie que similarity peut afficher une valeur basse alors que has_changes est false, parce qu'une section que vous ne diffez pas a dérivé sans que rien ne change dans la stratégie choisie.

Suivre un sous-ensemble de champs#

track_fields restreint le diff aux changements dont la section, le champ, ou la clé correspond à un terme suivi. Il accepte un objet dont les clés, plus toutes les valeurs de liste, deviennent les termes suivis. Ainsi {"metadata": ["title"]} suit à la fois la section metadata et le champ title. La correspondance se fait par token entier, pas par sous-chaîne : un terme price correspond à offers.price mais pas à priceCurrency.


Réponse#

POST, GET /v2/watch/{watcher_id}, PATCH, et DELETE renvoient tous le même objet watcher.

Champ Type Description
watcher_id string ID opaque (wat_...). Utilisez-le sur les routes par watcher.
url string L'URL surveillée.
status string active, paused, ou deleted.
frequency_minutes integer Cadence actuelle, après application du plancher horaire.
diff_mode string La stratégie de diff active.
track_fields object Le sous-ensemble de champs suivis, ou null.
webhook_url string Le webhook de changement, ou null.
notify_email boolean Indique si l'email au propriétaire est activé.
consecutive_errors integer Nombre d'échecs de rendu consécutifs. Remis à 0 lors d'une vérification réussie ; à 3, le watcher se met en pause automatiquement.
checks_count integer Nombre total de vérifications effectuées, réussies ou échouées.
last_check_at string Horodatage UTC de la dernière vérification, ou null.
next_check_at string Horodatage UTC de la prochaine vérification programmée. null tant que le watcher est en pause ou supprimé.
last_change_at string Horodatage UTC du dernier changement détecté, ou null.
created_at string Date de création du watcher.
updated_at string Dernière modification, ou null si jamais mis à jour.

GET /v2/watch renvoie un WatcherSummary compact par ligne (il omet diff_mode, track_fields, webhook_url, notify_email, et updated_at) enveloppé dans une pagination :

Champ Type Description
watchers array La page de résumés, du plus récent au plus ancien.
skip integer Le décalage (offset) demandé.
limit integer La taille de page en vigueur.
has_more boolean true s'il existe d'autres watchers au-delà de cette page.

Historique des snapshots#

GET /v2/watch/{watcher_id}/snapshots renvoie la chronologie des vérifications du watcher, du plus récent au plus ancien. Chaque entrée porte le verdict du diff. Le corps de la capture du snapshot lui-même vit dans le stockage et n'est pas renvoyé ici.

Champ Type Description
checked_at string Horodatage UTC de la vérification.
has_changes boolean Indique si cette vérification a détecté un changement.
similarity number Ratio global sur l'ensemble de la capture, de 0.0 à 1.0, ou null.
render_quality number Score de qualité de rendu pour la vérification, ou null.
change_count integer Nombre d'enregistrements de changement structurés.
changes array Le diff structuré : un enregistrement par changement, chacun avec section, kind (added/removed/modified), key, field, before, after.

À souligner. Les valeurs before et after à l'intérieur de changes sont du contenu brut de la page (texte de lien, métadonnées, valeurs JSON-LD), pas une sortie assainie. Si vous les affichez en HTML (un dashboard, un email), vous devez les échapper vous-même. Les valeurs de chaîne longues sont déjà tronquées à 2,000 caractères, et un seul diff est plafonné à 500 enregistrements de changement.


Cycle de vie et planification#

Un watcher traverse trois états :

  • active : sur le planning du poller. next_check_at est défini.
  • paused : hors planning (next_check_at vaut null). Atteint soit via PATCH {"status": "paused"}, soit automatiquement après trois échecs de rendu consécutifs.
  • deleted : la tombstone terminale, atteinte uniquement via DELETE. La ligne est conservée (pour que l'historique des vérifications survive) mais elle n'apparaît jamais dans les listes et renvoie 404 sur les routes par watcher.

Le plancher horaire est appliqué à deux endroits : à la création/mise à jour, et de nouveau par le scheduler lorsqu'il avance next_check_at. Ainsi, même une ligne dont la cadence a été modifiée directement dans la base de données ne peut jamais dépasser le plancher.

Mise en pause automatique#

Après trois échecs de rendu consécutifs, le watcher passe à paused, son planning est effacé, et le propriétaire reçoit un email de mise en pause si notify_email est activé. Une seule vérification réussie remet consecutive_errors à 0. Pour relancer un watcher en pause, remettez-le à active via PATCH, ce qui réarme next_check_at pour le prochain passage.

Suppression#

DELETE /v2/watch/{watcher_id} est un soft-delete : il fait passer status à deleted, efface next_check_at, et renvoie l'enregistrement tombstone avec un 200. C'est idempotent, donc supprimer un watcher déjà supprimé renvoie le même enregistrement inchangé. Les watchers supprimés cessent immédiatement de compter dans votre plafond max_watchers (seuls les watchers active comptent).


Exemples de code#

curl : création avec les valeurs par défaut#

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 : toutes les 6 heures, webhook plus suivi de table#

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 : pause, puis reprise#

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);

Réponses d'erreur#

Statut Condition
400 Bad Request L'URL se résout vers une adresse privée, loopback, link-local, ou de métadonnées (protection SSRF au moment de la création).
401 Unauthorized Clé API / token JWT manquant ou invalide.
402 Payment Required Watch n'est pas activé sur votre plan, ou votre nombre de watchers actifs a atteint max_watchers (également appliqué lors d'une reprise paused→active).
403 Forbidden /v2/watch ne figure pas dans les endpoints autorisés de la clé API.
404 Not Found watcher_id inconnu, appartenant à un autre projet, ou déjà soft-supprimé.
422 Unprocessable Entity frequency_minutes en dessous de 60 ou au-dessus de 43200, un corps PATCH vide ({}), une valeur d'énumération invalide dans diff_mode ou status, une url/webhook_url qui n'est pas en http(s), ou tout champ inconnu.
500 Internal Server Error Le watcher n'a pas pu être créé. Réessayez.

La référence complète des codes de statut se trouve dans le guide des codes d'erreur.


Limites#

Limite Valeur
Longueur de l'URL 2,048 caractères
Watchers actifs (max_watchers) 20 / 100 / 500 sur Indie / Studio / Production ; watch n'est pas disponible sur Founding
Ops par vérification 0 (les watchers ne puisent jamais dans le quota mensuel d'ops)
Longueur de webhook_url 2,048 caractères
frequency_minutes de 60 à 43,200 (1 heure à 30 jours)
Plancher horaire 60 minutes, appliqué à la création, à la mise à jour, et lors de la planification
Intervalle de scrutation 60 secondes (une vérification se déclenche dans la minute suivant son heure programmée)
Erreurs consécutives avant mise en pause automatique 3
Plancher de qualité de rendu (aucun diff en dessous) 0.4
Seuil de similarité pour changement de texte 0.98
Diff de texte unifié plafonné à 100 lignes
Enregistrements de changement par diff plafonné à 500
Valeur de chaîne par changement tronquée à 2,000 caractères
Corps de texte capturé diffé plafonné à 200,000 caractères
Taille de page liste / snapshot 20 par défaut, max 100

Questions fréquentes#

Comment configurer des webhooks pour surveiller les changements d'un site ?#

Passez webhook_url lors de la création du watcher avec POST /v2/watch, ou ajoutez-le plus tard via PATCH /v2/watch/{watcher_id}. Quand une vérification détecte un changement, EnConvert envoie un POST signé HMAC vers cette URL ; la cible est filtrée contre le SSRF immédiatement avant chaque envoi, et une chaîne vide sur PATCH l'efface.

À quelle fréquence l'API peut-elle vérifier les changements d'une page ?#

frequency_minutes définit la cadence, de 60 (le plancher horaire strict) à 43200 (30 jours). Le poller scanne toutes les 60 secondes, donc une vérification se déclenche dans la minute suivant son heure programmée.

Pourquoi mon watcher s'est-il mis en pause tout seul ?#

Trois échecs de rendu consécutifs mettent automatiquement un watcher en pause, effacent son planning, et envoient un email au propriétaire si notify_email est activé. Remettez-le via PATCH avec {"status": "active"} pour réarmer next_check_at ; une seule vérification réussie remet consecutive_errors à 0.

Puis-je surveiller seulement une partie d'une page, comme un prix ou une table ?#

Oui. track_fields restreint le diff aux changements dont la section, le champ, ou la clé correspond à un terme suivi (correspondance par token entier, donc price correspond à offers.price mais pas à priceCurrency), et diff_mode peut restreindre la détection à une seule stratégie telle que tables, structured, text, ou metadata.