---
seo_title: API de ingesta RAG: sitios y archivos a JSONL | EnConvert
meta_desc: Rastrea un sitio o sube archivos para RAG con /v2/ingest: renderiza o convierte, divide por encabezados y exporta un JSONL para LangChain, LlamaIndex o vector DB.
keywords: api para rastrear web para rag, ingerir archivos para rag api, pdf a jsonl para rag, web a jsonl langchain llamaindex, api de ingesta de datos rag, api de ingesta de documentos para llm, jsonl para base de datos vectorial, langchain jsonloader
---

# API para rastrear sitios web para RAG

`POST /v2/ingest` rastrea un sitio web para RAG: convierte un sitio (o una
lista explícita de URLs) en fragmentos listos para RAG y produce **un único
archivo JSONL** que se carga directamente en LangChain `JSONLoader`, LlamaIndex
`SimpleDirectoryReader` o una importación masiva a una BD vectorial. El endpoint
siempre es asíncrono: `POST` responde `202` con un `job_id`, tú consultas
`GET /v2/ingest/{job_id}` o registras un `webhook_url`, y un trabajo completado
te devuelve un `output_url` prefirmado para el JSONL. EnConvert realiza el
descubrimiento, el renderizado con Chrome headless, la fragmentación consciente
de encabezados y el ensamblado del JSONL en un solo trabajo.

Los **archivos** subidos se ingieren a través del mismo pipeline mediante
[`POST /v2/ingest/files`](#ingesting-files): PDF, DOCX, PPTX, XLSX, CSV, HTML,
EPUB y más se convierten a Markdown, se fragmentan y se ensamblan en el mismo
JSONL. Una sola integración cubre la ingesta RAG tanto de web como de archivos.

Esta es la llamada útil más pequeña. Rastrea un sitio y fragmenta cada página
que descubre:

```bash
curl -X POST https://api.enconvert.com/v2/ingest \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "crawl",
    "url": "https://example.com/docs"
  }'
```

La respuesta es el registro del trabajo, devuelto con `202 Accepted`. Observa
que el estado es `queued` y que `output_url` está ausente hasta que el trabajo
se completa:

```json
{
    "job_id": "ing_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7",
    "status": "queued",
    "mode": "crawl",
    "pages_discovered": 0,
    "pages_processed": 0,
    "pages_failed": 0,
    "total_chunks": 0,
    "webhook_delivered": false,
    "created_at": "2026-06-24T09:14:02.118Z"
}
```

La ingesta es **siempre asíncrona**. Cada página se renderiza en un navegador
real, lo que tarda entre 10 y 30 segundos por URL, muy por encima de la ventana
de solicitud de 300 segundos para cualquier trabajo no trivial. Por eso `POST`
responde `202` con un `job_id`, y un worker local en el droplet procesa el
trabajo fuera de banda. Consultas `GET /v2/ingest/{job_id}` para ver el
progreso, o registras un `webhook_url` para que te avisen cuando termine.

---

## Endpoints

| Método | Ruta | Propósito |
|--------|------|---------|
| `POST` | `/v2/ingest` | Crea un trabajo de ingesta **web** (lista de URLs, sitemap o rastreo). Responde `202` con un `job_id`. |
| `POST` | `/v2/ingest/files` | Crea un trabajo de ingesta de **archivos** a partir de documentos subidos (multipart). Mismo pipeline de trabajo + JSONL. |
| `GET` | `/v2/ingest` | Lista de los trabajos de este proyecto, del más reciente al más antiguo, con paginación `skip`/`limit`. |
| `GET` | `/v2/ingest/{job_id}` | Estado del ciclo de vida de un trabajo, con un `output_url` recién firmado una vez completado. |
| `DELETE` | `/v2/ingest/{job_id}` | Cancela un trabajo. El worker detecta el estado cancelado y se detiene entre páginas. |
| `POST` | `/v2/ingest/{job_id}/retry-webhook` | Vuelve a firmar y a enviar (POST) el webhook de finalización de un trabajo completado. |
| `GET` | `/v2/ingest/webhook-secret` | Revela el secreto de firma de webhooks del proyecto (canal del panel). |
| `POST` | `/v2/ingest/webhook-secret/rotate` | Rota el secreto de firma. Las firmas antiguas dejan de verificarse de inmediato. |

**Content-Type:** `application/json` en cada `POST`.

---

## Autenticación

Autentícate con una clave privada en la cabecera `X-API-Key` para las llamadas
de servidor a servidor. Esta es la vía que usan los ejemplos de abajo.

```http
X-API-Key: sk_your_private_key
```

Las claves públicas con un token bearer JWT también funcionan, usando el mismo
flujo que cualquier otro endpoint: genera un token con tu clave `pk_` y luego
envíalo como `Authorization: Bearer <token>`. El flujo completo, incluidos el
bloqueo por dominio y la renovación del token, está en [la guía de
autenticación](/es/docs/authentication.md).

Cada clave de API lleva una lista de endpoints permitidos. Si `/v2/ingest` no
está en la lista de la clave, la solicitud se rechaza con `403`. Una clave
limitada a `/v2/ingest` sigue alcanzando los trabajos que creó: `GET` y
`DELETE` `/v2/ingest/{job_id}` y `POST /v2/ingest/{job_id}/retry-webhook`
siempre están permitidos para un `job_id` (la forma `ing_…` se compara
explícitamente). El endpoint de listado estático y las dos rutas de gestión de
`webhook-secret` **no** heredan esa excepción; requieren un token más amplio o
con alcance de panel.

---

## Cómo funciona la ingesta

Un trabajo pasa por cinco fases, todas duraderas y a prueba de reinicios. Si el
proceso del worker se reinicia a mitad del trabajo, este se vuelve a encolar en
el arranque y se reanuda desde la página en la que se detuvo. Las páginas ya
completadas conservan su salida preparada y nunca se vuelven a renderizar ni a
facturar.

1. **Encolar.** `POST` valida la solicitud, ejecuta una comprobación rápida de
   ops `units=1` (el plan tiene la ingesta habilitada y margen en la cuota
   mensual de ops), inserta la fila del trabajo y responde `202`. No se persiste
   nada si la verificación de ops falla: un `402` no deja ninguna fila atrás.
2. **Descubrir.** En los modos `sitemap` y `crawl`, el worker ejecuta el mismo
   pase de descubrimiento que [el endpoint de descubrimiento](/es/docs/coming-soon/discover.md),
   limitado a `max_pages`, y filtra la URL semilla contra SSRF. En el modo
   `urls`, la lista explícita se deduplica en orden; no se ejecuta ningún
   descubrimiento. El tamaño del descubrimiento previo al límite se informa
   como `pages_found`; cuando el sitio tiene más URLs de las que `max_pages`
   permitió, `discovery_truncated` es `true` y una entrada en `warnings`
   indica ambos números, de modo que `pages_discovered` (el recuento
   encolado) nunca se confunde con el tamaño del sitio.
3. **Renderizar y fragmentar.** Cada URL se renderiza a través del singleton
   compartido de Chrome headless, el mismo pipeline de renderizado detrás de
   [el endpoint de percepción](/es/docs/endpoints/perceive.md). Después, el HTML
   renderizado se convierte a fit-Markdown y se divide con el fragmentador
   consciente de encabezados. Los renderizados se ejecutan de forma secuencial,
   una página a la vez.
4. **Preparar.** Los fragmentos de cada página se escriben en un objeto JSONL
   por página en el almacenamiento, con una clave determinista basada en
   `(project, job, url)`. Esto es lo que hace barato un reinicio: un trabajo
   reanudado reutiliza las páginas preparadas en lugar de volver a renderizarlas.
5. **Ensamblar.** Una vez terminadas todas las páginas, los objetos por página
   se concatenan en el `v2-ingest/{job_id}.jsonl` final, los objetos de
   preparación se eliminan, el trabajo pasa a `completed` y se dispara el
   webhook de finalización firmado si se estableció un `webhook_url`.

La cuota de ops se vuelve a comprobar **por página** dentro del worker, no
solo en el momento del envío; cada página completada factura una op. Un trabajo `crawl` cuyo número de páginas se
desconoce de antemano se detiene limpiamente en tu límite mensual: las páginas
ya renderizadas se facturan y se conservan, y las páginas restantes se marcan
como `skipped` en lugar de gastar de más.

Los renderizados de ingesta **no usan credenciales por diseño**. A diferencia de
`/v2/perceive`, no acepta `auth`, `cookies` ni `headers` personalizados. No se
persiste nada secreto para la reanudación duradera, por lo que el estado del
trabajo en disco nunca lleva credenciales.

---

## Ingesta de archivos {: #ingesting-files }

`POST /v2/ingest` rastrea la web; `POST /v2/ingest/files` ingiere **archivos
subidos** a través del mismo pipeline. Ambos crean el mismo trabajo, ejecutan el
mismo fragmentador consciente de encabezados y producen el mismo entregable
JSONL único, así que una sola integración cubre la ingesta RAG de web *y* de
archivos.

Envía los documentos como `multipart/form-data` en el campo `files`. Cada
archivo se convierte a Markdown mediante el conversor
[anything-to-markdown](/es/docs/endpoints/convert/documents/anything-to-markdown.md), y luego se fragmenta y
ensambla exactamente igual que una página rastreada. Se acepta cada
[formato de entrada admitido](/es/docs/endpoints/convert/documents/anything-to-markdown.md#supported-input-formats):
PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, OpenDocument y texto plano/Markdown.

```bash
curl -X POST https://api.enconvert.com/v2/ingest/files \
  -H "X-API-Key: sk_your_private_key" \
  -F "files=@handbook.pdf" \
  -F "files=@pricing.xlsx" \
  -F "files=@faq.docx" \
  -F "max_words=700" \
  -F "webhook_url=https://your-app.example.com/hooks/ingest"
```

La respuesta es el mismo `IngestJobResponse` que el endpoint de rastreo, con
`mode` establecido en `files`:

```json
{
    "job_id": "ing_7c1d8e2f4a5b6c7d8e9f0a1b2c3d4e5f",
    "status": "queued",
    "mode": "files",
    "pages_discovered": 0,
    "pages_processed": 0,
    "pages_failed": 0,
    "total_chunks": 0,
    "webhook_delivered": false,
    "created_at": "2026-07-14T10:15:30.220Z"
}
```

Cada archivo subido cuenta como una «página»: factura una op,
incrementa `pages_processed` a medida que se completa, y se
etiqueta con su **nombre de archivo** en el `metadata.source_url` del JSONL.
Consultas `GET /v2/ingest/{job_id}`, cancelas con `DELETE` y recibes el webhook
de finalización firmado exactamente igual que en un trabajo de rastreo. Los
archivos se almacenan solo hasta que se ensambla el JSONL, y luego se eliminan.

### Parámetros de la solicitud de archivos

Se envían como campos de formulario multipart (no como cuerpo JSON):

| Campo | Tipo | Predeterminado | Descripción |
|-------|------|---------|-------------|
| `files` | file[] | -- | Uno o más documentos a ingerir. 1–200 archivos por solicitud; cada uno se comprueba en tamaño contra el límite de subida de tu plan. |
| `max_words` | `integer` | `512` | Límite flexible de palabras por fragmento. 32–4,000. Los bloques de código y las tablas con barras verticales se mantienen atómicos. |
| `sentence_overlap` | `integer` | `1` | Frases repetidas entre fragmentos de prosa consecutivos de la misma sección. 0–10. |
| `webhook_url` | `string` | `null` | Callback de finalización firmado con HMAC, con la misma política de firma y reintentos que los [webhooks de finalización](#webhooks-de-finalizacion) de abajo. |

Un tipo de archivo no admitido, un archivo vacío o una imagen (no se realiza
OCR) se rechaza en el envío con un `400`; un archivo que supere el límite de
tamaño por archivo de tu plan devuelve un `413`.

```python
import requests

BASE = "https://api.enconvert.com"
HEADERS = {"X-API-Key": "sk_your_private_key"}

# Submit several files (always 202).
with open("handbook.pdf", "rb") as a, open("pricing.xlsx", "rb") as b:
    job = requests.post(
        f"{BASE}/v2/ingest/files",
        headers=HEADERS,
        files=[("files", ("handbook.pdf", a)), ("files", ("pricing.xlsx", b))],
        data={"max_words": 700},
    ).json()

# Poll GET /v2/ingest/{job_id} exactly as for a crawl job, then download output_url.
print(job["job_id"], job["status"], job["mode"])  # -> ing_...  queued  files
```

---

## Parámetros de la solicitud

### Origen y modo

| Parámetro | Tipo | Predeterminado | Descripción |
|-----------|------|---------|-------------|
| `mode` | `string` | `"urls"` | `urls`, `sitemap` o `crawl`. Selecciona cómo se construye el conjunto de URLs. |
| `url` | `string` | `null` | URL semilla para el modo `sitemap`/`crawl`. Debe empezar por `http://` o `https://`. Máximo 2,048 caracteres. Obligatoria para esos modos; rechazada en el modo `urls`. |
| `urls` | `string[]` | `null` | URLs explícitas a ingerir en el modo `urls`. No vacía, máximo 1,000 entradas, cada una `http(s)` y ≤ 2,048 caracteres. Obligatoria para el modo `urls`; rechazada en el modo `sitemap`/`crawl`. |

`url` y `urls` son mutuamente excluyentes: envía exactamente un origen. El
modo `urls` requiere `urls`; `sitemap` y `crawl` requieren una `url` semilla.
Enviar la incorrecta para el modo devuelve un `422`.

### Descubrimiento (modos sitemap / crawl)

Estos se reenvían al pase de descubrimiento y se ignoran en el modo `urls`.

| Parámetro | Tipo | Predeterminado | Descripción |
|-----------|------|---------|-------------|
| `max_pages` | `integer` | `50` | Límite de URLs descubiertas **y** ingeridas. 1–1,000. |
| `max_depth` | `integer` | `2` | Profundidad de enlaces de rastreo desde la semilla. 1–5. |
| `same_domain_only` | `boolean` | `true` | Restringe el descubrimiento al dominio de la semilla. |
| `include_patterns` | `string[]` | `[]` | Patrones regex que una URL debe cumplir para conservarse. Máximo 50. Cada uno se compila en el envío; un patrón inválido devuelve un `422`. |
| `exclude_patterns` | `string[]` | `[]` | Patrones regex que descartan una URL coincidente. Máximo 50. |
| `respect_robots` | `boolean` | `false` | Cuando es `true`, se omite una URL no permitida por el `robots.txt` del sitio. |

### Renderizado

| Parámetro | Tipo | Predeterminado | Descripción |
|-----------|------|---------|-------------|
| `wait_for` | `string` | `null` | Espera tras la navegación a un selector CSS o expresión JS antes de capturar. Máximo 1,024 caracteres. |
| `wait_timeout_ms` | `integer` | `30000` | Cuánto puede esperar `wait_for`, en milisegundos. 0–60,000. |

> **Nota.** La ingesta **no** acepta `auth`, `cookies` ni `headers`.
> Si una página necesita credenciales para renderizarse, la ingesta es la
> herramienta equivocada. Usa [el endpoint de percepción](/es/docs/endpoints/perceive.md),
> que ofrece toda la superficie de solicitud autenticada, para esa única página.

### Fragmentación (objeto `chunk`)

| Parámetro | Tipo | Predeterminado | Restricciones | Descripción |
|-----------|------|---------|-------------|-------------|
| `max_words` | `integer` | `512` | 32–4,000 | Límite flexible de palabras por fragmento. Consciente de encabezados. Los bloques de código y las tablas con barras verticales se mantienen atómicos y pueden superarlo. |
| `sentence_overlap` | `integer` | `1` | 0–10 | Frases repetidas entre fragmentos de prosa consecutivos de la misma sección. `0` desactiva el solapamiento. El solapamiento nunca cruza un límite de encabezado. |

El fragmentador divide en los encabezados `#`, `##` y `###`, de modo que cada
fragmento pertenece exactamente a una sección y lleva su ruta de encabezados
completa. Los encabezados más profundos (`####`–`######`) permanecen en línea
como contenido.
Los bloques de código con delimitadores y las tablas Markdown nunca se dividen,
incluso cuando un solo bloque supera `max_words`; los elementos de lista se
dividen entre elementos, nunca a mitad de un elemento.

### Webhook

| Parámetro | Tipo | Predeterminado | Descripción |
|-----------|------|---------|-------------|
| `webhook_url` | `string` | `null` | Endpoint que recibe el callback de finalización firmado con HMAC. Máximo 2,048 caracteres. Se comprueba el esquema en el envío; se filtra contra SSRF en el momento de la *entrega*, no en el del envío. |

---

## Respuesta

`POST`, `GET /v2/ingest/{job_id}` y `DELETE` devuelven todos el mismo objeto
`IngestJobResponse`.

| Campo | Tipo | Descripción |
|-------|------|-------------|
| `job_id` | `string` | ID opaco (`ing_…`). Úsalo con los endpoints GET/DELETE y menciónalo al soporte. |
| `status` | `string` | `queued`, `discovering`, `processing`, `completed`, `failed` o `canceled`. |
| `mode` | `string` | El modo que enviaste: `urls`, `sitemap`, `crawl` o `files`. |
| `pages_discovered` | `integer` | Elementos que el trabajo realmente encoló: URLs (la lista explícita, o el resultado del descubrimiento limitado a `max_pages`), o archivos subidos. `pages_processed` + `pages_failed` suman este valor una vez que el trabajo alcanza un estado terminal. |
| `pages_found` | `integer` | URLs únicas elegibles que el descubrimiento produjo **antes** del límite de `max_pages`. Para trabajos `sitemap` es el recuento único real del sitio; para trabajos `crawl` es una cota inferior (el rastreo deja de solicitar páginas al alcanzar el límite). Ausente en trabajos `urls` y `files`. |
| `discovery_truncated` | `boolean` | `true` cuando el descubrimiento encontró más URLs únicas de las que `max_pages` permitió encolar. Una entrada en `warnings` detalla los números; aumenta `max_pages` para ingerir más del sitio. |
| `pages_processed` | `integer` | URLs cuyo renderizado → fragmentación → preparación se completó. |
| `pages_failed` | `integer` | URLs que no se pudieron renderizar o se omitieron (p. ej. cuota de ops agotada). |
| `total_chunks` | `integer` | Total de fragmentos escritos en todas las páginas completadas. Coincide con el número de líneas del JSONL. |
| `output_url` | `string` | URL de descarga prefirmada del JSONL final. Presente solo cuando `status` es `completed`; expira tras 15 minutos. |
| `error_message` | `string` | Se establece cuando `status` es `failed` (p. ej. descubrimiento rechazado, todas las páginas fallaron). |
| `webhook_url` | `string` | El destino del webhook de finalización registrado para este trabajo, si lo hay. |
| `webhook_delivered` | `boolean` | `true` una vez que el webhook de finalización firmado obtuvo un `2xx`. |
| `created_at` | `string` | Cuándo se creó el trabajo (UTC). |
| `completed_at` | `string` | Cuándo el trabajo alcanzó un estado terminal (UTC). |
| `warnings` | `string[]` | Notas no fatales, p. ej. truncamiento del descubrimiento: `"discovery found 719 unique URLs; the job was capped at max_pages=50, so 50 pages were enqueued. Raise max_pages to ingest more of the site."` |

> **Nota.** `POST` y el `GET`/`DELETE` por trabajo usan
> `response_model_exclude_none`, por lo que los campos que aún son `null` (como
> `output_url` antes de completarse) se omiten del JSON en lugar de enviarse
> como `null`.

### La forma del registro JSONL

El archivo final es JSON delimitado por saltos de línea. Cada línea es un
fragmento:

```json
{"id":"9f2b8c1ad4e5-0000","content":"Pricing is usage-based...","metadata":{"source_url":"https://example.com/pricing","title":"Pricing","headings_path":["Pricing","Plans"],"section":"Plans","word_count":118,"chunk_index":0}}
```

| Campo | Tipo | Descripción |
|-------|------|-------------|
| `id` | `string` | Determinista por `(source_url, chunk_index)`: `<md5(url)[:12]>-<index:04d>`. Una nueva ejecución produce ids idénticos. |
| `content` | `string` | El texto recuperable del fragmento. Se corresponde con `Document.page_content` en LangChain. |
| `metadata.source_url` | `string` | La página de la que provino el fragmento. |
| `metadata.title` | `string` | El `<title>` de la página, con recurso al primer `<h1>`, limitado a 512 caracteres. |
| `metadata.headings_path` | `string[]` | La ruta `h1 → h2 → h3` bajo la que se sitúa el fragmento. |
| `metadata.section` | `string` | El texto del encabezado más interno (la última entrada de `headings_path`). |
| `metadata.word_count` | `integer` | Número de palabras de `content` delimitadas por espacios en blanco. |
| `metadata.chunk_index` | `integer` | El índice del fragmento dentro de su página. |

El archivo es UTF-8, escrito con `ensure_ascii=false`, por lo que el unicode se
mantiene legible. Como `content` es una cadena de nivel superior y `metadata` es
un objeto hermano, el mismo archivo se carga en LangChain
`JSONLoader(content_key="content", json_lines=True)`, LlamaIndex
`SimpleDirectoryReader` y cualquier importación a una BD vectorial orientada a
líneas sin reestructurarlo.

---

## Ciclo de vida del trabajo y sondeo

Un trabajo pasa por estos estados:

```text
queued → discovering → processing → completed | failed | canceled
```

| Estado | Significado |
|--------|---------|
| `queued` | Aceptado y a la espera del worker. |
| `discovering` | Ejecutando el pase de descubrimiento de sitemap/crawl (solo `sitemap`/`crawl`). |
| `processing` | Renderizando y fragmentando páginas. `pages_processed` y `total_chunks` aumentan en vivo. |
| `completed` | El JSONL final está ensamblado; `output_url` está firmado y listo. |
| `failed` | El descubrimiento fue rechazado, o todas las páginas fallaron o se omitieron. `error_message` lo explica. |
| `canceled` | Un `DELETE` alcanzó el trabajo antes de que terminara. |

Consulta el estado con el `GET` por trabajo. Es de solo lectura: no consume
ops y vuelve a firmar el `output_url` a partir de la clave del objeto
almacenado en cada llamada:

```bash
curl https://api.enconvert.com/v2/ingest/ing_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7 \
  -H "X-API-Key: sk_your_private_key"
```

Un `job_id` desconocido, o uno que pertenece a otro proyecto, devuelve `404`.
La existencia nunca se filtra entre proyectos.

### Listado de trabajos

`GET /v2/ingest` devuelve los trabajos de este proyecto del más reciente al más
antiguo, con los parámetros de consulta `skip` y `limit`. `limit` es `20` por
defecto y tiene un tope de `100`. La respuesta lleva una bandera `has_more` en
lugar de un recuento total:

```bash
curl "https://api.enconvert.com/v2/ingest?skip=0&limit=20" \
  -H "X-API-Key: sk_your_private_key"
```

```json
{
    "jobs": [
        {
            "job_id": "ing_3f9a...",
            "status": "completed",
            "mode": "crawl",
            "pages_discovered": 42,
            "pages_found": 42,
            "discovery_truncated": false,
            "pages_processed": 41,
            "pages_failed": 1,
            "total_chunks": 1187,
            "output_url": "https://spaces.example.com/...signed...",
            "webhook_configured": true,
            "webhook_delivered": true,
            "created_at": "2026-06-24T09:14:02.118Z",
            "completed_at": "2026-06-24T09:31:55.402Z"
        }
    ],
    "skip": 0,
    "limit": 20,
    "has_more": false
}
```

Las filas del listado reducen `webhook_url` a un booleano
`webhook_configured`, así que el listado nunca devuelve el endpoint sin
procesar en la tabla.

### Cancelación de un trabajo

`DELETE /v2/ingest/{job_id}` establece el estado del trabajo en `canceled`. El
worker lee ese estado entre páginas y se detiene sin ensamblar la salida. La
cancelación es idempotente y a prueba de carreras: si el ensamblado ya se
confirmó, el `DELETE` no coincide con nada y el trabajo se devuelve sin cambios
como `completed`. Un trabajo terminado nunca se revierte a `canceled`.

```bash
curl -X DELETE \
  https://api.enconvert.com/v2/ingest/ing_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7 \
  -H "X-API-Key: sk_your_private_key"
```

---

## Webhooks de finalización

Establece `webhook_url` en el `POST` y EnConvert envía un POST firmado con HMAC
cuando el trabajo se completa. La carga útil es un JSON compacto y ordenado por
claves:

```json
{"job_id":"ing_3f9a...","output_url":"https://spaces.example.com/...signed...","pages_processed":41,"status":"completed","total_chunks":1187}
```

La entrega se reintenta hasta tres veces tras el primer intento, con retardos de
espera de 1, 4 y 16 segundos, es decir, cuatro POST en el peor caso. Cada
intento se vuelve a firmar con una marca de tiempo nueva, de modo que una
cadena de reintentos lenta nunca sobrepasa la ventana de frescura del
consumidor. Una respuesta 2xx es un éxito. Un endpoint caído se registra como
una no entrega y genera una alerta en el panel, pero nunca hunde un trabajo que
por lo demás está completado.

El `webhook_url` se **filtra contra SSRF en el momento de la entrega**, no en el
del envío. Una URL que se resuelve en una dirección privada, de loopback o de
metadatos se almacena de forma inerte y solo se rechaza cuando EnConvert intenta
enviarle un POST.

### Verificación de la firma

Cada entrega lleva dos cabeceras:

| Cabecera | Valor |
|--------|-------|
| `X-Enconvert-Signature` | `sha256=<hex>`, el HMAC-SHA256 de `<timestamp>.<raw body>`. |
| `X-Enconvert-Timestamp` | La marca de tiempo en segundos unix vinculada a la firma. |

La entrada de firma es la marca de tiempo, un `.` literal y luego el cuerpo de
solicitud sin procesar. Vincular la marca de tiempo al MAC significa que un
consumidor que rechaza marcas de tiempo caducadas obtiene protección contra
reproducción gratis. La ventana de frescura por defecto es de **300 segundos**.
Verifica en tu manejador:

```python
import hashlib
import hmac
import time

SECRET = "whsec_your_signing_secret"   # from GET /v2/ingest/webhook-secret
TOLERANCE_SECONDS = 300


def verify(raw_body: bytes, signature_header: str, timestamp_header: str) -> bool:
    if not signature_header or not timestamp_header:
        return False
    try:
        ts = int(timestamp_header)
    except ValueError:
        return False
    if abs(time.time() - ts) > TOLERANCE_SECONDS:
        return False  # replayed or badly skewed clock

    provided = signature_header.removeprefix("sha256=")
    expected = hmac.new(
        SECRET.encode("utf-8"),
        f"{ts}.".encode("utf-8") + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, provided)
```

### Gestión del secreto de firma

`GET /v2/ingest/webhook-secret` revela el secreto del proyecto (creándolo en la
primera llamada) junto con los nombres de las cabeceras y la tolerancia que
necesita tu consumidor. Es **sensible** y solo se expone a través del canal
autenticado del panel:

```json
{
    "secret": "whsec_...",
    "signature_header": "X-Enconvert-Signature",
    "timestamp_header": "X-Enconvert-Timestamp",
    "signature_scheme": "sha256",
    "replay_tolerance_seconds": 300,
    "rotated": false
}
```

`POST /v2/ingest/webhook-secret/rotate` emite un nuevo secreto y establece
`rotated` en `true`. Toda firma calculada con el secreto anterior deja de
verificarse en el momento en que se confirma la rotación. Rota tras una fuga
sospechada y luego actualiza tu consumidor.

### Reenvío de un webhook

Si tu endpoint estaba caído cuando el trabajo terminó,
`POST /v2/ingest/{job_id}/retry-webhook` vuelve a firmar y a enviar (POST) con la
misma política de reintentos:

```bash
curl -X POST \
  https://api.enconvert.com/v2/ingest/ing_3f9a.../retry-webhook \
  -H "X-API-Key: sk_your_private_key"
```

```json
{
    "job_id": "ing_3f9a...",
    "delivered": true,
    "attempts": 1,
    "status_code": 200,
    "detail": "Delivered (HTTP 200)."
}
```

Devuelve `404` para un `job_id` desconocido o ajeno, `400` cuando no hay ningún
`webhook_url` configurado (o la URL almacenada ahora se resuelve en una
dirección privada/interna), y `409` cuando el trabajo no ha alcanzado
`completed`.

---

## Ejemplos de código

### curl: lista explícita de URLs

```bash
curl -X POST https://api.enconvert.com/v2/ingest \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "urls",
    "urls": [
      "https://example.com/docs/intro",
      "https://example.com/docs/quickstart",
      "https://example.com/docs/api"
    ]
  }'
```

### curl: rastreo con fragmentación y un webhook

```bash
curl -X POST https://api.enconvert.com/v2/ingest \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "crawl",
    "url": "https://example.com/docs",
    "max_pages": 200,
    "max_depth": 3,
    "include_patterns": ["/docs/"],
    "chunk": {"max_words": 700, "sentence_overlap": 2},
    "webhook_url": "https://your-app.example.com/hooks/ingest"
  }'
```

### Python: enviar, sondear, descargar

```python
import time

import requests

BASE = "https://api.enconvert.com"
HEADERS = {"X-API-Key": "sk_your_private_key"}

# 1. Submit (always 202).
job = requests.post(
    f"{BASE}/v2/ingest",
    headers=HEADERS,
    json={"mode": "crawl", "url": "https://example.com/docs", "max_pages": 100},
).json()
job_id = job["job_id"]

# 2. Poll until terminal.
while True:
    job = requests.get(f"{BASE}/v2/ingest/{job_id}", headers=HEADERS).json()
    if job["status"] in ("completed", "failed", "canceled"):
        break
    time.sleep(5)

# 3. Download the JSONL from its signed URL.
if job["status"] == "completed":
    jsonl = requests.get(job["output_url"]).text
    print(f"{job['total_chunks']} chunks across "
          f"{job['pages_processed']} pages")
    print(jsonl.splitlines()[0])
```

### Node.js: enviar y sondear

```javascript
const BASE = "https://api.enconvert.com";
const HEADERS = {
    "Content-Type": "application/json",
    "X-API-Key": "sk_your_private_key"
};

// 1. Submit.
const submit = await fetch(`${BASE}/v2/ingest`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({
        mode: "crawl",
        url: "https://example.com/docs",
        max_pages: 100
    })
});
let job = await submit.json();

// 2. Poll until terminal.
while (!["completed", "failed", "canceled"].includes(job.status)) {
    await new Promise((r) => setTimeout(r, 5000));
    const poll = await fetch(`${BASE}/v2/ingest/${job.job_id}`, {
        headers: { "X-API-Key": HEADERS["X-API-Key"] }
    });
    job = await poll.json();
}

// 3. Download the JSONL.
if (job.status === "completed") {
    const jsonl = await fetch(job.output_url).then((r) => r.text());
    console.log(`${job.total_chunks} chunks`);
    console.log(jsonl.split("\n")[0]);
}
```

---

## Respuestas de error

| Estado | Condición |
|--------|-----------|
| `202 Accepted` | El trabajo se creó y se encoló. Este es el resultado normal de `POST`. |
| `401 Unauthorized` | Clave de API / token JWT ausente o inválido. |
| `402 Payment Required` | La ingesta no está en tu plan actual, o tu cuota mensual de ops está agotada. |
| `403 Forbidden` | `/v2/ingest` no está en los endpoints permitidos de la clave de API. |
| `404 Not Found` | `job_id` desconocido, o uno propiedad de otro proyecto. |
| `409 Conflict` | `retry-webhook` llamado en un trabajo que no ha alcanzado `completed`. |
| `400 Bad Request` | `retry-webhook` llamado sin ningún `webhook_url` configurado, o su URL almacenada ahora se resuelve en una dirección privada/interna. |
| `422 Unprocessable Entity` | El origen no coincide con el modo (`urls` sin `urls`, o una `url` semilla en el modo `urls`); un parámetro está fuera de rango; o un regex de `include_patterns`/`exclude_patterns` no compila. |
| `500 Internal Server Error` | El trabajo no se pudo crear. El mensaje incluye el `job_id` que mencionar al soporte. |

Un fallo de renderizado a nivel de página **no** hace fallar la solicitud ni el
trabajo. Incrementa `pages_failed`, coloca el error de la página en su propia
fila y el trabajo continúa. Un trabajo solo `fails` cuando el descubrimiento es
rechazado o todas las páginas fallan o se omiten. La referencia completa de
códigos de estado está en [la guía de códigos de error](/es/docs/reference/errors.md).

---

## Límites

| Límite | Valor |
|-------|-------|
| URLs por solicitud en modo `urls` | 1,000 |
| Longitud de `url` / de cada entrada de `urls` | 2,048 caracteres |
| `max_pages` (límite de descubrimiento) | 1–1,000 |
| `max_depth` | 1–5 |
| `include_patterns` / `exclude_patterns` | 50 cada uno |
| Longitud de `wait_for` | 1,024 caracteres |
| `wait_timeout_ms` | 0–60,000 ms |
| `chunk.max_words` | 32–4,000 (predeterminado 512) |
| `chunk.sentence_overlap` | 0–10 (predeterminado 1) |
| Longitud de `webhook_url` | 2,048 caracteres |
| Techo de páginas por trabajo (`MAX_PAGES_PER_JOB`) | 1,000 |
| Archivos por solicitud a `/v2/ingest/files` | 1–200 |
| Tamaño de subida por archivo | Depende del plan (Founding: 5 MB) |
| `limit` del listado `GET /v2/ingest` | 1–100 (predeterminado 20) |
| Expiración del `output_url` firmado | 15 minutos |
| Intentos de entrega del webhook | 4 (inicial + 3 reintentos) |
| Tolerancia de reproducción del webhook | 300 segundos |
| Ops mensuales (compartidas entre todos los endpoints, 1 por página) | 500 / 3.000 / 15.000 / 50.000 según el nivel; consulta [precios](/es/pricing.md) |

---

## Preguntas frecuentes

### ¿Cómo rastreo un sitio web para RAG con una API?

Envía `POST /v2/ingest` con `mode: "crawl"` y una `url` semilla. La llamada responde `202` con un `job_id`; el worker descubre páginas, renderiza cada una en Chrome headless, fragmenta el Markdown de forma consciente de encabezados y ensambla un archivo JSONL que descargas desde el `output_url` firmado.

### ¿Cómo ingiero archivos (PDF, documentos de Word) para RAG?

Envía `POST /v2/ingest/files` como `multipart/form-data` con uno o más `files`. Cada documento se convierte a Markdown, se fragmenta de forma consciente de encabezados y se ensambla en el mismo JSONL único que un trabajo de rastreo, así que un solo pipeline cubre web y archivos. Se admiten archivos PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, OpenDocument y de texto plano/Markdown (hasta 200 por solicitud); la lista completa está en la página [anything-to-markdown](/es/docs/endpoints/convert/documents/anything-to-markdown.md#supported-input-formats).

### ¿La salida JSONL se carga directamente en LangChain y LlamaIndex?

Sí. Cada línea lleva una cadena `content` de nivel superior con un objeto `metadata` hermano, por lo que el mismo archivo se carga en LangChain `JSONLoader(content_key="content", json_lines=True)`, LlamaIndex `SimpleDirectoryReader` y cualquier importación a una BD vectorial orientada a líneas sin reestructurarlo.

### ¿Cómo divide el fragmentador las páginas en fragmentos para RAG?

Divide en los encabezados `#`, `##` y `###` con un límite flexible `max_words` (predeterminado `512`, rango 32–4,000) y un `sentence_overlap` opcional. Los bloques de código con delimitadores y las tablas Markdown nunca se dividen, y cada fragmento lleva su `headings_path` completo.

### ¿Cómo me notifican cuando termina un trabajo de ingesta?

Establece `webhook_url` en el `POST` y EnConvert envía un callback firmado con HMAC (cabeceras `X-Enconvert-Signature` y `X-Enconvert-Timestamp`) con hasta tres reintentos tras el primer intento. Si tu endpoint estaba caído, `POST /v2/ingest/{job_id}/retry-webhook` lo vuelve a firmar y a entregar.

### ¿Por qué falta output_url en mi respuesta de ingesta?

`output_url` solo está presente una vez que `status` es `completed`. La respuesta del `POST` es un trabajo `queued` con el campo omitido. Consulta `GET /v2/ingest/{job_id}`, que no consume ops y vuelve a firmar la URL en cada llamada; cada URL firmada expira tras 15 minutos.
