---
seo_title: Watch (Phase 2) : détection de changements | EnConvert
meta_desc: Bêta privée, Phase 2 : surveillez n'importe quelle URL à une cadence horaire ou plus lente, avec un webhook ou un email dès que le contenu de la page change.
keywords: api détection de changement de site, comment configurer un webhook pour surveiller un site, api de surveillance de changement de page avec webhook, surveiller les changements d'une page web par api, api de comparaison de site web, api de suivi de changement de prix, suivi des changements d'url api, notification webhook de changement de page web
---

# API de détection de changements de site

<div class="alert alert-warning">
<strong>Bêta privée.</strong> 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 <a href="/fr/docs/coming-soon">Bientôt disponible</a>, et chaque publication est annoncée dans <a href="/fr/changelog">le changelog</a>.
</div>

`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](/fr/docs/endpoints/perceive.md) : 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 :

```bash
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 :

```json
{
    "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.

```http
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](/fr/docs/authentication.md).

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](/fr/docs/endpoints/perceive.md). 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](#diff-modes). |
| `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](#tracking-a-subset-of-fields). |
| `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](/fr/docs/concepts/v1-and-v2.md).

### 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-modes }

`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 {: #tracking-a-subset-of-fields }

`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

```bash
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

```bash
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

```bash
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

```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

```javascript
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](/fr/docs/reference/errors.md).

---

## 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`.
