API de Detección de Cambios en Sitios Web#
POST /v2/watch es una API de detección de cambios en sitios web: registras
una página una sola vez, y un planificador local del droplet la vuelve a
renderizar con una cadencia fija en Chrome headless real, compara cada
captura con la anterior y te notifica (mediante webhook firmado con HMAC,
email, o ambos) cuando la página realmente cambia. Reemplazará el cron job
más el diffing más el entramado de alertas que de otro modo tendrías que
montar en torno a el endpoint de perceive: un solo
registro en lugar de un planificador, un bucket de almacenamiento y un
script de comparación.
Esta es la llamada mínima útil. Envía una URL y recibes de vuelta un watcher programado para verificar cada hora:
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 respuesta es el registro completo del watcher. Está active de
inmediato, y next_check_at se fija en el siguiente tick del 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étodo | Ruta | Propósito |
|---|---|---|
POST |
/v2/watch |
Crea un watcher para una URL. Devuelve 201. |
GET |
/v2/watch |
Lista los watchers de este proyecto, del más reciente al más antiguo. |
GET |
/v2/watch/{watcher_id} |
Obtiene el registro completo de un watcher. |
GET |
/v2/watch/{watcher_id}/snapshots |
Lista el historial de verificaciones de un watcher, del más reciente al más antiguo. |
PATCH |
/v2/watch/{watcher_id} |
Actualiza la cadencia, la configuración de diff, o pausa/reanuda. |
DELETE |
/v2/watch/{watcher_id} |
Elimina un watcher (soft-delete, idempotente). |
Content-Type: application/json en POST y PATCH.
Autenticación#
Autentica con una clave privada en el encabezado X-API-Key para llamadas
de servidor a servidor. Este es el flujo que usan todos los ejemplos a
continuación.
X-API-Key: sk_your_private_key
Las claves públicas con un token JWT bearer también funcionan, usando el
mismo flujo que todos los demás endpoints: genera un token con tu clave
pk_ y luego envíalo como Authorization: Bearer <token>. El flujo
completo, incluido el bloqueo de dominio y la renovación de tokens, está en
la guía de autenticación.
Cada clave de API lleva una allowlist de endpoints permitidos. Si
/v2/watch no está en la lista de la clave, la solicitud de creación se
rechaza con 403. Una vez que una clave puede crear watchers, también
puede acceder a las rutas por watcher que le pertenecen: GET, PATCH y
DELETE en /v2/watch/{watcher_id} y su página /snapshots se permiten
automáticamente, así que no tienes que incluir cada verbo por separado en
la allowlist.
Cómo funciona watch#
No hay ninguna cola externa detrás de esto: ni Google Cloud Tasks, ni un
planificador de terceros. El cronograma vive por completo en la columna de
base de datos next_check_at, y un poller interno del proceso lo controla:
- Crear. Haces un
POSTde una URL. La URL se filtra contra SSRF (un host privado, loopback o de metadatos se rechaza antes de escribir cualquier fila), se genera un IDwat_, y el watcher se guarda comoactiveconnext_check_atfijado en el momento actual. - Reclamar. Un
watch_workerlocal del droplet escanea cada 60 segundos en busca de filas activas cuyonext_check_atya haya pasado. Las reclama conFOR UPDATE SKIP LOCKEDy avanza el cronograma de cada una un intervalo completo dentro de la misma transacción, así que un renderizado lento nunca se reclama dos veces y un fallo a mitad del renderizado simplemente salta un ciclo. - Renderizar. Cada watcher reclamado se renderiza una vez a través del singleton compartido de Chrome headless, el mismo pipeline de captura que hay detrás de el endpoint de perceive. El renderizado no usa credenciales: no se almacenan auth, cookies ni encabezados, así que no queda nada secreto en reposo para la verificación recurrente.
- Puntuar y comparar. Un renderizado con una puntuación por debajo del piso de calidad (0.4) o marcado como bloqueado se registra solo como verificación de auditoría, sin hash de contenido, por lo que nunca se convierte en línea base de diff ni dispara una notificación. Un buen renderizado se convierte en una captura (texto del contenido principal más la estructura extraída), se compara contra la última captura buena, y el veredicto se escribe en una fila de snapshot.
- Notificar. Cuando el diff reporta un cambio, el webhook firmado con
HMAC (si está configurado) y el email del propietario (si
notify_emailestá activo) se disparan simultáneamente, con la mejor entrega posible. - Reprogramar. El worker escribe el siguiente
next_check_at. Tres fallos de renderizado consecutivos pausan el watcher y envían un email al propietario en lugar de reprogramar.
Como el cronograma es una columna de base de datos, el tiempo de inactividad no necesita lógica de recuperación: el primer tick tras el arranque recoge todo lo que estaba atrasado.
Parámetros de la solicitud#
Crear (POST /v2/watch)#
| Parámetro | Tipo | Predeterminado | Descripción |
|---|---|---|---|
url |
string |
ninguno | La página a monitorear. Debe comenzar con http:// o https://. Máximo 2,048 caracteres. Obligatorio. |
frequency_minutes |
integer |
60 |
Minutos entre verificaciones. Piso horario estricto: mínimo 60, máximo 43200 (30 días). |
diff_mode |
string |
"auto" |
Qué estrategia de diff aplicar. auto, text, structured, tables, o metadata. Consulta Modos de diff. |
track_fields |
object |
null |
Subconjunto opcional de campos/selectores para acotar qué cuenta como cambio. Consulta Rastrear un subconjunto de campos. |
webhook_url |
string |
null |
Destino opcional para la notificación de cambios. Firmado con HMAC, filtrado contra SSRF justo antes de cada entrega. Máximo 2,048 caracteres; debe ser http(s). |
notify_email |
boolean |
true |
Envía un email al propietario del proyecto cuando se detecta un cambio y en caso de auto-pausa. |
El esquema de la solicitud es estricto (extra="forbid"): un campo
desconocido se rechaza con 422. Deliberadamente no hay superficie para
auth, cookies ni headers aquí, porque los watchers permanecen sin
credenciales, la misma postura que
el endpoint de ingest.
Actualizar (PATCH /v2/watch/{watcher_id})#
Todos los campos son opcionales; solo se aplican las claves presentes en el cuerpo.
| Parámetro | Tipo | Descripción |
|---|---|---|
frequency_minutes |
integer |
Nueva cadencia. Los mismos límites 60–43200 que en la creación. |
diff_mode |
string |
Cambia la estrategia de diff. |
track_fields |
object |
Reemplaza el subconjunto de campos rastreados. |
webhook_url |
string |
Establece un nuevo webhook. Una cadena vacía es la señal explícita de "borrarlo" y almacena NULL. |
notify_email |
boolean |
Activa o desactiva el email al propietario. |
status |
string |
active o paused. Reanudar rearma el cronograma (next_check_at se fija en el siguiente tick); pausar lo limpia para que el poller deje de reclamar la fila. |
Un cuerpo vacío ({}) se rechaza con 422 en lugar de ignorarse
silenciosamente. Ten en cuenta que aquí status solo acepta active o
paused. El estado terminal deleted se alcanza mediante DELETE,
nunca mediante PATCH. Reanudar un watcher pausado cuenta como agregar
un monitor activo, así que vuelve a comprobar el mismo límite de
max_watchers que la creación y puede devolver 402.
Modos de diff#
diff_mode elige cuál de las cuatro estrategias, sensibles al tipo de
contenido, ejecuta el motor. auto ejecuta las cuatro y combina sus
resultados; los modos con nombre restringen el diff a una sola estrategia.
diff_mode |
Estrategia | Qué señala |
|---|---|---|
auto (predeterminado) |
Las cuatro siguientes | Todo tipo de cambio en una sola pasada. |
text |
Ratio SequenceMatcher del contenido principal |
El texto del cuerpo cambió, se señala cuando la similitud cae por debajo de 0.98, de modo que una palabra reordenada o un cambio de espacios en blanco no hace flapear al watcher. Incluye un diff unificado limitado a 100 líneas. |
structured |
Coincidencia de listas con clave | Elementos agregados / eliminados / modificados por campo entre enlaces (emparejados por href) y bloques JSON-LD (emparejados por @type + name). No depende del orden. |
tables |
Coincidencia por encabezado de contexto | Las tablas se emparejan por título/encabezado; reporta cambios en el número de filas, tablas agregadas/eliminadas, y ediciones de contenido con el mismo número de filas (limitado a 100 filas de contexto). |
metadata |
Comparación de diccionario clave por clave | Campos de metadatos de página agregados, eliminados y modificados. |
Sin importar qué modo elijas, el similarity del snapshot siempre es el
ratio general de toda la captura (0.0–1.0). En un modo restringido eso
significa que similarity puede mostrarse bajo mientras has_changes es
false, porque una sección que no estás comparando se movió mientras nada
cambió en la estrategia que elegiste.
Rastrear un subconjunto de campos#
track_fields acota el diff a los cambios cuya sección, campo o clave
coincide con un término rastreado. Acepta un objeto cuyas claves, más
cualquier valor de lista, se convierten en los términos rastreados. Así,
{"metadata": ["title"]} rastrea tanto la sección metadata como el
campo title. La coincidencia es por token completo, no por subcadena:
un término price coincide con offers.price pero no con
priceCurrency.
Respuesta#
POST, GET /v2/watch/{watcher_id}, PATCH y DELETE devuelven todos
el mismo objeto watcher.
| Campo | Tipo | Descripción |
|---|---|---|
watcher_id |
string |
ID opaco (wat_...). Úsalo en las rutas por watcher. |
url |
string |
La URL monitoreada. |
status |
string |
active, paused, o deleted. |
frequency_minutes |
integer |
Cadencia actual, después del piso horario. |
diff_mode |
string |
La estrategia de diff activa. |
track_fields |
object |
El subconjunto de campos rastreados, o null. |
webhook_url |
string |
El webhook de cambios, o null. |
notify_email |
boolean |
Si el email al propietario está activo. |
consecutive_errors |
integer |
Fallos de renderizado consecutivos. Se reinicia a 0 en una verificación exitosa; al llegar a 3 el watcher se auto-pausa. |
checks_count |
integer |
Total de verificaciones ejecutadas, exitosas o fallidas. |
last_check_at |
string |
Marca de tiempo UTC de la verificación más reciente, o null. |
next_check_at |
string |
Marca de tiempo UTC de la próxima verificación programada. null mientras está pausado o eliminado. |
last_change_at |
string |
Marca de tiempo UTC del cambio detectado más reciente, o null. |
created_at |
string |
Cuándo se creó el watcher. |
updated_at |
string |
Última modificación, o null si nunca se actualizó. |
GET /v2/watch devuelve un WatcherSummary compacto por fila (omite
diff_mode, track_fields, webhook_url, notify_email y
updated_at) envuelto en un sobre de paginación:
| Campo | Tipo | Descripción |
|---|---|---|
watchers |
array |
La página de resúmenes, del más reciente al más antiguo. |
skip |
integer |
El offset que solicitaste. |
limit |
integer |
El tamaño de página en vigor. |
has_more |
boolean |
true cuando existen más watchers más allá de esta página. |
Historial de snapshots#
GET /v2/watch/{watcher_id}/snapshots devuelve la línea de tiempo de
verificaciones del watcher, de la más reciente a la más antigua. Cada
entrada lleva el veredicto del diff. El cuerpo de la captura del
snapshot en sí vive en el almacenamiento y no se devuelve aquí.
| Campo | Tipo | Descripción |
|---|---|---|
checked_at |
string |
Marca de tiempo UTC de la verificación. |
has_changes |
boolean |
Si esta verificación detectó un cambio. |
similarity |
number |
Ratio general de toda la captura, 0.0–1.0, o null. |
render_quality |
number |
Puntuación de calidad de renderizado de la verificación, o null. |
change_count |
integer |
Número de registros de cambio estructurados. |
changes |
array |
El diff estructurado: un registro por cambio, cada uno con section, kind (added/removed/modified), key, field, before, after. |
Vale la pena aclarar. Los valores
beforeyafterdentro dechangesson contenido de página sin procesar (texto de enlaces, metadatos, valores JSON-LD), no una salida saneada. Si los renderizas en HTML (un dashboard, un email), debes escaparlos tú mismo. Los valores de cadena largos ya vienen truncados a 2,000 caracteres, y un solo diff está limitado a 500 registros de cambio.
Ciclo de vida y programación#
Un watcher atraviesa tres estados:
active: en el cronograma del poller.next_check_atestá definido.paused: fuera del cronograma (next_check_atesnull). Se alcanza mediantePATCH {"status": "paused"}o automáticamente después de tres fallos de renderizado consecutivos.deleted: lápida terminal, alcanzada solo medianteDELETE. La fila se conserva (para que el historial de verificaciones sobreviva) pero nunca aparece en las listas y se lee como404en las rutas por watcher.
El piso horario se aplica en dos lugares: al crear/actualizar, y otra vez
por el planificador cuando avanza next_check_at. Así, incluso una fila
cuya cadencia se editó directamente en la base de datos nunca puede
superar el piso.
Auto-pausa#
Después de tres fallos de renderizado consecutivos, el watcher se
pone en paused, se limpia su cronograma, y el propietario recibe un
email de watcher pausado si notify_email está activo. Una sola
verificación exitosa reinicia consecutive_errors a 0. Para reiniciar
un watcher pausado, hazle un PATCH de vuelta a active, lo cual rearma
next_check_at para el siguiente tick.
Eliminación#
DELETE /v2/watch/{watcher_id} es un soft-delete: cambia status a
deleted, limpia next_check_at, y devuelve el registro con lápida con
un 200. Es idempotente, así que eliminar un watcher ya eliminado devuelve
el mismo registro sin cambios. Los watchers eliminados dejan de contar
contra tu límite de max_watchers de inmediato (solo cuentan los
watchers active).
Ejemplos de código#
curl: crear con valores predeterminados#
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: cada 6 horas, webhook más rastreo de tablas#
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: pausar y luego reanudar#
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);
Respuestas de error#
| Estado | Condición |
|---|---|
400 Bad Request |
La URL resuelve a una dirección privada, loopback, link-local o de metadatos (protección SSRF al crear). |
401 Unauthorized |
Falta la clave de API / token JWT, o es inválida. |
402 Payment Required |
Watch no está habilitado en tu plan, o tu número de watchers activos ha alcanzado max_watchers (también se aplica al reanudar de paused→active). |
403 Forbidden |
/v2/watch no está en los endpoints permitidos de la clave de API. |
404 Not Found |
watcher_id desconocido, uno perteneciente a otro proyecto, o uno ya eliminado (soft-delete). |
422 Unprocessable Entity |
frequency_minutes por debajo de 60 o por encima de 43200, un cuerpo PATCH vacío ({}), un enum inválido en diff_mode o status, una url/webhook_url que no es http(s), o cualquier campo desconocido. |
500 Internal Server Error |
No se pudo crear el watcher. Reinténtalo. |
La referencia completa de códigos de estado está en la guía de códigos de error.
Límites#
| Límite | Valor |
|---|---|
| Longitud de la URL | 2,048 caracteres |
Watchers activos (max_watchers) |
20 / 100 / 500 en Indie / Studio / Production; watch no está disponible en Founding |
| Ops por comprobación | 0 (los watchers nunca consumen la cuota mensual de ops) |
Longitud de webhook_url |
2,048 caracteres |
frequency_minutes |
60–43,200 (1 hora a 30 días) |
| Piso horario | 60 minutos, aplicado al crear, actualizar y programar |
| Intervalo de sondeo | 60 segundos (una verificación se dispara dentro del minuto de su hora programada) |
| Errores consecutivos antes de auto-pausa | 3 |
| Piso de calidad de renderizado (sin diff por debajo) | 0.4 |
| Umbral de similitud para cambios de texto | 0.98 |
| Diff de texto unificado | limitado a 100 líneas |
| Registros de cambio por diff | limitado a 500 |
| Valor de cadena por cambio | truncado a 2,000 caracteres |
| Cuerpo de texto capturado comparado | limitado a 200,000 caracteres |
| Tamaño de página de lista / snapshot | predeterminado 20, máximo 100 |
Preguntas frecuentes#
¿Cómo configuro webhooks para el monitoreo de cambios en un sitio?#
Pasa webhook_url al crear el watcher con POST /v2/watch, o agrégalo
después mediante PATCH /v2/watch/{watcher_id}. Cuando una verificación
detecta un cambio, EnConvert envía un POST firmado con HMAC a esa URL; el
destino se filtra contra SSRF justo antes de cada entrega, y una cadena
vacía en PATCH lo borra.
¿Con qué frecuencia puede la API verificar cambios en una página?#
frequency_minutes define la cadencia, de 60 (el piso horario
estricto) a 43200 (30 días). El poller escanea cada 60 segundos, así
que una verificación se dispara dentro del minuto de su hora programada.
¿Por qué mi watcher se pausó solo?#
Tres fallos de renderizado consecutivos auto-pausan un watcher, limpian
su cronograma y envían un email al propietario si notify_email está
activo. Hazle un PATCH de vuelta con {"status": "active"} para
rearmar next_check_at; una sola verificación exitosa reinicia
consecutive_errors a 0.
¿Puedo monitorear solo parte de una página, como un precio o una tabla?#
Sí. track_fields acota el diff a los cambios cuya sección, campo o
clave coincide con un término rastreado (coincidencia por token
completo, así que price coincide con offers.price pero no con
priceCurrency), y diff_mode puede restringir la detección a una sola
estrategia como tables, structured, text, o metadata.