---
seo_title: API ingest RAG: crawl siti e file in JSONL | EnConvert
meta_desc: Crawl di un sito o upload di file per RAG con /v2/ingest: rendering o conversione, chunk per titoli ed export in un JSONL per LangChain, LlamaIndex o vector DB.
keywords: api per crawl sito web per rag, ingestione file per rag api, da pdf a jsonl per rag, da sito web a jsonl langchain llamaindex, api ingestione dati rag, api ingestione documenti per llm, jsonl per vector database, langchain jsonloader
---

# API per il crawl di siti web per il RAG

`POST /v2/ingest` esegue il crawl di un sito web per il RAG: trasforma un
sito (oppure un elenco esplicito di URL) in chunk pronti per il RAG ed emette
**un unico file JSONL** che si carica direttamente in LangChain `JSONLoader`,
LlamaIndex `SimpleDirectoryReader` o in un import massivo su vector DB.
L'endpoint è sempre asincrono: `POST` risponde `202` con un `job_id`, tu
interroghi `GET /v2/ingest/{job_id}` oppure registri un `webhook_url`, e un
job completato restituisce un `output_url` pre-firmato per il JSONL.
EnConvert si occupa della discovery, del rendering in headless Chrome, del
chunking basato sui titoli e dell'assemblaggio del JSONL in un unico job.

Anche i **file** caricati vengono ingeriti attraverso la stessa identica
pipeline tramite [`POST /v2/ingest/files`](#ingesting-files): PDF, DOCX,
PPTX, XLSX, CSV, HTML, EPUB e altri vengono convertiti in Markdown,
suddivisi in chunk e assemblati nello stesso JSONL. Una sola integrazione
copre l'ingestione RAG sia dal web sia dai file.

Ecco la chiamata utile più piccola. Fai il crawl di un sito e suddividi in
chunk ogni pagina che scopre:

```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 risposta è il record del job, restituito con `202 Accepted`. Nota che lo
stato è `queued` e che `output_url` è assente finché il job non si 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"
}
```

L'ingestione è **sempre asincrona**. Ogni pagina viene renderizzata in un
browser reale, con tempi di 10–30 secondi per URL, ben oltre la finestra di
richiesta di 300 secondi per qualunque job non banale. Perciò `POST`
risponde `202` con un `job_id`, e un worker locale sul droplet smaltisce il
job fuori banda. Tu interroghi `GET /v2/ingest/{job_id}` per lo stato di
avanzamento, oppure registri un `webhook_url` per essere avvisato quando
finisce.

---

## Endpoint

| Metodo | Path | Scopo |
|--------|------|---------|
| `POST` | `/v2/ingest` | Crea un job di ingestione **web** (elenco di URL, sitemap o crawl). Risponde `202` con un `job_id`. |
| `POST` | `/v2/ingest/files` | Crea un job di ingestione **file** da documenti caricati (multipart). Stessa pipeline job + JSONL. |
| `GET` | `/v2/ingest` | Elenco dei job di questo progetto, dal più recente, con paginazione `skip`/`limit`. |
| `GET` | `/v2/ingest/{job_id}` | Stato del ciclo di vita di un singolo job, con un `output_url` firmato di fresco una volta completato. |
| `DELETE` | `/v2/ingest/{job_id}` | Annulla un job. Il worker vede lo stato annullato e si ferma tra una pagina e l'altra. |
| `POST` | `/v2/ingest/{job_id}/retry-webhook` | Rifirma e ri-invia in `POST` il webhook di completamento per un job completato. |
| `GET` | `/v2/ingest/webhook-secret` | Rivela il secret di firma dei webhook del progetto (canale dashboard). |
| `POST` | `/v2/ingest/webhook-secret/rotate` | Ruota il secret di firma. Le vecchie firme smettono di verificarsi immediatamente. |

**Content-Type:** `application/json` su ogni `POST`.

---

## Autenticazione

Autenticati con una chiave privata nell'header `X-API-Key` per le chiamate
server-to-server. È il metodo usato dagli esempi qui sotto.

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

Funzionano anche le chiavi pubbliche con un bearer token JWT, seguendo lo
stesso flusso di ogni altro endpoint: genera un token con la tua chiave `pk_`,
poi invialo come `Authorization: Bearer <token>`. Il flusso completo, incluso
il domain locking e il refresh del token, è nella [guida
all'autenticazione](/it/docs/authentication.md).

Ogni chiave API porta con sé un'allowlist di endpoint consentiti. Se
`/v2/ingest` non è nell'elenco della chiave, la richiesta viene rifiutata con
`403`. Una chiave limitata a `/v2/ingest` raggiunge comunque i job che ha
creato: `GET` e `DELETE` `/v2/ingest/{job_id}` e
`POST /v2/ingest/{job_id}/retry-webhook` sono sempre consentiti per un
`job_id` (la forma `ing_…` viene riconosciuta esplicitamente). L'endpoint di
elenco statico e le due route di gestione `webhook-secret` **non** ereditano
questa deroga; richiedono un token più ampio o con scope dashboard.

---

## Come funziona l'ingestione

Un job attraversa cinque fasi, tutte durevoli e sicure al riavvio. Se il
processo worker si riavvia a metà job, il job viene rimesso in coda all'avvio
e riprende dalla pagina su cui si era fermato: le pagine già completate
mantengono il loro output in staging e non vengono mai renderizzate né
fatturate di nuovo.

1. **Queue.** `POST` valida la richiesta, esegue un rapido controllo di ops
   `units=1` (il piano ha l'ingestione abilitata e margine nella quota
   mensile di ops), inserisce la riga del job e risponde `202`. Nulla viene
   persistito se il gate di ops fallisce: un `402` non lascia alcuna riga.
2. **Discover.** Per le modalità `sitemap` e `crawl` il worker esegue lo
   stesso passaggio di discovery dell'[endpoint discover](/it/docs/coming-soon/discover.md),
   limitato a `max_pages`, e sottopone l'URL seed a screening SSRF. Per la
   modalità `urls` l'elenco esplicito viene deduplicato nell'ordine dato; non
   viene eseguita alcuna discovery. La dimensione della discovery prima del
   limite viene riportata come `pages_found`; quando il sito ha più URL di
   quanti `max_pages` abbia consentito, `discovery_truncated` è `true` e una
   voce in `warnings` riporta entrambi i numeri, così `pages_discovered` (il
   conteggio degli elementi in coda) non viene mai scambiato per la
   dimensione del sito.
3. **Render and chunk.** Ogni URL viene renderizzato attraverso il singleton
   condiviso di headless Chrome, cioè la stessa pipeline di rendering dietro
   [l'endpoint perceive](/it/docs/endpoints/perceive.md). Poi l'HTML renderizzato viene
   convertito in fit-Markdown e suddiviso dal chunker basato sui titoli. I
   rendering vengono eseguiti in sequenza, una pagina alla volta.
4. **Stage.** I chunk di ogni pagina vengono scritti in un oggetto JSONL per
   pagina nello storage, con chiave deterministica `(project, job, url)`. È
   questo che rende economico un riavvio: un job ripreso riutilizza le pagine
   in staging invece di renderizzarle di nuovo.
5. **Assemble.** Una volta terminata ogni pagina, gli oggetti per pagina
   vengono concatenati nel `v2-ingest/{job_id}.jsonl` finale, gli oggetti di
   staging vengono eliminati, il job passa a `completed` e, se era stato
   impostato un `webhook_url`, parte il webhook di completamento firmato.

La quota di ops viene ricontrollata **per pagina** all'interno del
worker, non solo al momento dell'invio; ogni pagina completata addebita
una op. Un job `crawl` il cui numero di
pagine è ignoto in partenza si ferma in modo pulito al tuo tetto mensile: le
pagine già renderizzate vengono fatturate e conservate, e le pagine restanti
vengono marcate `skipped` anziché sforare la spesa.

I rendering di ingestione sono **privi di credenziali per progettazione**. A
differenza di `/v2/perceive`, non accetta `auth`, `cookies` o `headers`
personalizzati: nessun segreto viene persistito per la ripresa durevole,
quindi lo stato del job su disco non trasporta mai credenziali.

---

## Ingestione dei file {: #ingesting-files }

`POST /v2/ingest` esegue il crawl del web; `POST /v2/ingest/files` ingerisce
i **file caricati** attraverso la stessa identica pipeline. Entrambi creano
lo stesso job, usano lo stesso chunker basato sui titoli e producono lo
stesso unico deliverable JSONL, così una sola integrazione copre
l'ingestione RAG dal web *e* dai file.

Invia i documenti come `multipart/form-data` nel campo `files`. Ogni file
viene convertito in Markdown dal convertitore
[anything-to-markdown](/it/docs/endpoints/convert/documents/anything-to-markdown.md), poi suddiviso in chunk
e assemblato esattamente come una pagina sottoposta a crawl. Viene accettato
ogni [formato di input supportato](/it/docs/endpoints/convert/documents/anything-to-markdown.md#supported-input-formats):
PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, OpenDocument e testo
semplice/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 risposta è lo stesso `IngestJobResponse` dell'endpoint di crawl, con
`mode` impostato su `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"
}
```

Ogni file caricato conta come una «pagina»: fattura una op,
incrementa `pages_processed` man mano che si completa ed è etichettato con il
suo **nome file** nel `metadata.source_url` del JSONL. Tu interroghi
`GET /v2/ingest/{job_id}`, annulli con `DELETE` e ricevi il webhook di
completamento firmato esattamente come per un job di crawl. I file vengono
conservati solo finché il JSONL non è assemblato, poi vengono eliminati.

### Parametri della richiesta file

Inviati come campi form multipart (non un body JSON):

| Campo | Tipo | Default | Descrizione |
|-------|------|---------|-------------|
| `files` | file[] | -- | Uno o più documenti da ingerire. 1–200 file per richiesta; la dimensione di ciascuno viene controllata rispetto al limite di upload del tuo piano. |
| `max_words` | `integer` | `512` | Tetto morbido di parole per chunk. 32–4,000. I blocchi di codice e le tabelle pipe restano atomici. |
| `sentence_overlap` | `integer` | `1` | Frasi ripetute tra chunk di prosa consecutivi della stessa sezione. 0–10. |
| `webhook_url` | `string` | `null` | Callback di completamento firmato in HMAC, con la stessa firma e la stessa policy di retry dei [webhook di completamento](#webhook-di-completamento) più sotto. |

Un tipo di file non supportato, un file vuoto o un'immagine (l'OCR non viene
eseguito) viene rifiutato all'invio con un `400`; un file oltre il limite di
dimensione per file del tuo piano dà 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
```

---

## Parametri della richiesta

### Sorgente e modalità

| Parametro | Tipo | Default | Descrizione |
|-----------|------|---------|-------------|
| `mode` | `string` | `"urls"` | `urls`, `sitemap` o `crawl`. Seleziona come viene costruito l'insieme di URL. |
| `url` | `string` | `null` | URL seed per la modalità `sitemap`/`crawl`. Deve iniziare con `http://` o `https://`. Massimo 2,048 caratteri. Obbligatorio per quelle modalità; rifiutato in modalità `urls`. |
| `urls` | `string[]` | `null` | URL espliciti da ingerire in modalità `urls`. Non vuoto, massimo 1,000 voci, ciascuna `http(s)` e ≤ 2,048 caratteri. Obbligatorio per la modalità `urls`; rifiutato in modalità `sitemap`/`crawl`. |

`url` e `urls` sono mutuamente esclusivi: indica esattamente una sorgente. La
modalità `urls` richiede `urls`; `sitemap` e `crawl` richiedono un `url`
seed. Inviare quello sbagliato per la modalità dà un `422`.

### Discovery (modalità sitemap / crawl)

Questi vengono inoltrati al passaggio di discovery e ignorati in modalità `urls`.

| Parametro | Tipo | Default | Descrizione |
|-----------|------|---------|-------------|
| `max_pages` | `integer` | `50` | Tetto sugli URL individuati **e** ingeriti. 1–1,000. |
| `max_depth` | `integer` | `2` | Profondità dei link nel crawl a partire dal seed. 1–5. |
| `same_domain_only` | `boolean` | `true` | Limita la discovery al dominio del seed. |
| `include_patterns` | `string[]` | `[]` | Pattern regex che un URL deve soddisfare per essere mantenuto. Massimo 50. Ciascuno viene compilato all'invio; un pattern errato dà un `422`. |
| `exclude_patterns` | `string[]` | `[]` | Pattern regex che scartano un URL corrispondente. Massimo 50. |
| `respect_robots` | `boolean` | `false` | Quando è `true`, un URL non consentito dal `robots.txt` del sito viene saltato. |

### Rendering

| Parametro | Tipo | Default | Descrizione |
|-----------|------|---------|-------------|
| `wait_for` | `string` | `null` | Dopo la navigazione, attende un selettore CSS o un'espressione JS prima di catturare. Massimo 1,024 caratteri. |
| `wait_timeout_ms` | `integer` | `30000` | Per quanto tempo `wait_for` può attendere, in millisecondi. 0–60,000. |

> **Nota.** L'ingestione **non** accetta `auth`, `cookies` o `headers`.
> Se una pagina ha bisogno di credenziali per essere renderizzata,
> l'ingestione è lo strumento sbagliato: per quella singola pagina usa
> [l'endpoint perceive](/it/docs/endpoints/perceive.md), che offre l'intera superficie
> di richiesta autenticata.

### Chunking (oggetto `chunk`)

| Parametro | Tipo | Default | Vincoli | Descrizione |
|-----------|------|---------|-------------|-------------|
| `max_words` | `integer` | `512` | 32–4,000 | Tetto morbido di parole per chunk. Basato sui titoli. I blocchi di codice e le tabelle pipe restano atomici e possono superarlo. |
| `sentence_overlap` | `integer` | `1` | 0–10 | Frasi ripetute tra chunk di prosa consecutivi della stessa sezione. `0` disabilita la sovrapposizione. La sovrapposizione non attraversa mai un confine di titolo. |

Il chunker suddivide sui titoli `#`, `##` e `###`: ogni chunk appartiene
esattamente a una sezione e porta con sé il suo percorso completo dei titoli.
I titoli più profondi (`####`–`######`) restano inline come contenuto. I
blocchi di codice recintati e le tabelle Markdown non vengono mai suddivisi,
anche quando un singolo blocco supera `max_words`; gli elementi di elenco si
suddividono tra un elemento e l'altro, mai a metà elemento.

### Webhook

| Parametro | Tipo | Default | Descrizione |
|-----------|------|---------|-------------|
| `webhook_url` | `string` | `null` | Endpoint che riceve il callback di completamento firmato in HMAC. Massimo 2,048 caratteri. Lo schema viene controllato all'invio; lo screening SSRF avviene al momento della *consegna*, non all'invio. |

---

## Risposta

`POST`, `GET /v2/ingest/{job_id}` e `DELETE` restituiscono tutti lo stesso
oggetto `IngestJobResponse`.

| Campo | Tipo | Descrizione |
|-------|------|-------------|
| `job_id` | `string` | ID opaco (`ing_…`). Usalo con gli endpoint GET/DELETE e citalo al supporto. |
| `status` | `string` | `queued`, `discovering`, `processing`, `completed`, `failed` o `canceled`. |
| `mode` | `string` | La modalità che hai inviato: `urls`, `sitemap`, `crawl` o `files`. |
| `pages_discovered` | `integer` | Elementi effettivamente messi in coda dal job: URL (l'elenco esplicito, o il risultato della discovery limitato a `max_pages`) o file caricati. `pages_processed` + `pages_failed` sommano a questo valore una volta che il job è terminale. |
| `pages_found` | `integer` | URL unici idonei che la discovery ha prodotto **prima** del limite `max_pages`. Per i job `sitemap` è il vero conteggio unico del sito; per i job `crawl` è un limite inferiore (il crawl smette di scaricare al raggiungimento del limite). Assente per i job `urls` e `files`. |
| `discovery_truncated` | `boolean` | `true` quando la discovery ha trovato più URL unici di quanti `max_pages` abbia permesso al job di mettere in coda. Una voce in `warnings` riporta entrambi i numeri; aumenta `max_pages` per ingerire una parte maggiore del sito. |
| `pages_processed` | `integer` | URL la cui sequenza render → chunk → stage è completata. |
| `pages_failed` | `integer` | URL che non sono riusciti a renderizzarsi o sono stati saltati (es. quota di ops esaurita). |
| `total_chunks` | `integer` | Chunk totali scritti su tutte le pagine completate. Corrisponde al numero di righe del JSONL. |
| `output_url` | `string` | URL di download pre-firmato per il JSONL finale. Presente solo quando `status` è `completed`; scade dopo 15 minuti. |
| `error_message` | `string` | Impostato quando `status` è `failed` (es. discovery rifiutata, tutte le pagine fallite). |
| `webhook_url` | `string` | La destinazione del webhook di completamento registrata per questo job, se presente. |
| `webhook_delivered` | `boolean` | `true` una volta che il webhook di completamento firmato ha ricevuto un `2xx`. |
| `created_at` | `string` | Quando il job è stato creato (UTC). |
| `completed_at` | `string` | Quando il job ha raggiunto uno stato terminale (UTC). |
| `warnings` | `string[]` | Note non fatali, es. troncamento della discovery: `"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` e i `GET`/`DELETE` per singolo job usano
> `response_model_exclude_none`, quindi i campi che sono ancora `null` (come
> `output_url` prima del completamento) vengono omessi dal JSON anziché
> inviati come `null`.

### La forma del record JSONL

Il file finale è JSON delimitato da newline. Ogni riga è un chunk:

```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 | Descrizione |
|-------|------|-------------|
| `id` | `string` | Deterministico per `(source_url, chunk_index)`: `<md5(url)[:12]>-<index:04d>`. Una riesecuzione produce id identici. |
| `content` | `string` | Il testo del chunk recuperabile. Corrisponde a `Document.page_content` in LangChain. |
| `metadata.source_url` | `string` | La pagina da cui proviene il chunk. |
| `metadata.title` | `string` | Il `<title>` della pagina, con fallback al primo `<h1>`, limitato a 512 caratteri. |
| `metadata.headings_path` | `string[]` | Il percorso `h1 → h2 → h3` sotto cui si trova il chunk. |
| `metadata.section` | `string` | Il testo del titolo più interno (l'ultima voce di `headings_path`). |
| `metadata.word_count` | `integer` | Conteggio di parole di `content` delimitate da spazi. |
| `metadata.chunk_index` | `integer` | L'indice del chunk all'interno della sua pagina. |

Il file è UTF-8, scritto con `ensure_ascii=false`, così l'unicode resta
leggibile. Poiché `content` è una stringa di primo livello e `metadata` è un
oggetto affiancato, lo stesso file si carica tramite LangChain
`JSONLoader(content_key="content", json_lines=True)`, LlamaIndex
`SimpleDirectoryReader` e qualunque import vector-DB orientato alle righe
senza rimodellare nulla.

---

## Ciclo di vita del job e polling

Un job attraversa questi stati:

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

| Status | Significato |
|--------|---------|
| `queued` | Accettato e in attesa del worker. |
| `discovering` | Esegue il passaggio di discovery sitemap/crawl (solo `sitemap`/`crawl`). |
| `processing` | Rendering e chunking delle pagine. `pages_processed` e `total_chunks` crescono in tempo reale. |
| `completed` | Il JSONL finale è assemblato; `output_url` è firmato e pronto. |
| `failed` | La discovery è stata rifiutata, oppure ogni pagina è fallita o è stata saltata. `error_message` lo spiega. |
| `canceled` | Un `DELETE` ha raggiunto il job prima che finisse. |

Interroga lo stato con il `GET` per singolo job. È di sola lettura, non
consuma ops e rifirma l'`output_url` dalla chiave dell'oggetto memorizzato
a ogni chiamata:

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

Un `job_id` sconosciuto, o appartenente a un progetto diverso, restituisce
`404`. L'esistenza non viene mai rivelata tra progetti diversi.

### Elenco dei job

`GET /v2/ingest` restituisce i job di questo progetto dal più recente, con i
query param `skip` e `limit`. `limit` vale per default `20` ed è limitato a
`100`. La risposta porta un flag `has_more` invece di un conteggio totale:

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

Le righe dell'elenco riducono `webhook_url` a un booleano
`webhook_configured`: l'elenco non ripete mai l'endpoint grezzo nella
tabella.

### Annullamento di un job

`DELETE /v2/ingest/{job_id}` imposta lo stato del job su `canceled`. Il
worker legge quello stato tra una pagina e l'altra e si ferma senza
assemblare l'output. L'annullamento è idempotente e a prova di race: se
l'assemblaggio è già stato confermato, il `DELETE` non trova corrispondenze e
il job viene restituito invariato come `completed`. Un job terminato non
viene mai riportato a `canceled`.

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

---

## Webhook di completamento

Imposta `webhook_url` sul `POST` ed EnConvert invia un POST firmato in HMAC
quando il job si completa. Il payload è JSON compatto, con chiavi ordinate:

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

La consegna ritenta fino a tre volte dopo il primo tentativo, con ritardi di
back-off di 1, 4 e 16 secondi, cioè quattro POST nel caso peggiore. Ogni
tentativo viene rifirmato con un timestamp aggiornato, così una catena di
retry lenta non esce mai dalla finestra di freschezza del consumatore. Una
risposta 2xx è un successo; un endpoint morto viene registrato come mancata
consegna (e genera un alert nella dashboard), non affonda mai un job per il
resto completato.

Il `webhook_url` viene **sottoposto a screening SSRF al momento della
consegna**, non all'invio. Un URL che si risolve in un indirizzo privato, di
loopback o di metadata viene memorizzato in modo inerte e rifiutato solo
quando EnConvert prova a inviargli un POST.

### Verifica della firma

Ogni consegna porta due header:

| Header | Valore |
|--------|-------|
| `X-Enconvert-Signature` | `sha256=<hex>`, cioè l'HMAC-SHA256 di `<timestamp>.<raw body>`. |
| `X-Enconvert-Timestamp` | Il timestamp in secondi unix legato alla firma. |

L'input di firma è il timestamp, un `.` letterale, poi il body grezzo della
richiesta. Legare il timestamp al MAC significa che un consumatore che
rifiuta timestamp scaduti ottiene gratis la protezione dai replay. La
finestra di freschezza predefinita è di **300 secondi**. Verifica nel tuo
handler:

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

### Gestione del secret di firma

`GET /v2/ingest/webhook-secret` rivela il secret del progetto (creandolo alla
prima chiamata) insieme ai nomi degli header e alla tolleranza di cui il tuo
consumatore ha bisogno. È **sensibile** ed esposto solo tramite il canale
dashboard autenticato:

```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` emette un nuovo secret e imposta
`rotated` su `true`. Ogni firma calcolata con il secret precedente smette di
verificarsi nel momento in cui la rotazione viene confermata. Ruota dopo un
sospetto leak, poi aggiorna il tuo consumatore.

### Riconsegna di un webhook

Se il tuo endpoint era offline quando il job è terminato,
`POST /v2/ingest/{job_id}/retry-webhook` rifirma e ri-invia in POST con la
stessa policy di retry:

```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)."
}
```

Restituisce `404` per un `job_id` sconosciuto o estraneo, `400` quando non è
configurato alcun `webhook_url` (o l'URL memorizzato ora si risolve in un
indirizzo privato/interno) e `409` quando il job non ha raggiunto
`completed`.

---

## Esempi di codice

### curl: elenco esplicito di URL

```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: crawl con chunking e 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: invio, polling, download

```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: invio e polling

```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]);
}
```

---

## Risposte di errore

| Status | Condizione |
|--------|-----------|
| `202 Accepted` | Il job è stato creato e messo in coda. È il normale esito del `POST`. |
| `401 Unauthorized` | Chiave API / token JWT mancante o non valido. |
| `402 Payment Required` | L'ingestione non è nel tuo piano attuale, oppure la tua quota mensile di ops è esaurita. |
| `403 Forbidden` | `/v2/ingest` non è tra gli endpoint consentiti della chiave API. |
| `404 Not Found` | `job_id` sconosciuto, o appartenente a un altro progetto. |
| `409 Conflict` | `retry-webhook` chiamato su un job che non ha raggiunto `completed`. |
| `400 Bad Request` | `retry-webhook` chiamato senza alcun `webhook_url` configurato, o il cui URL memorizzato ora si risolve in un indirizzo privato/interno. |
| `422 Unprocessable Entity` | La sorgente non corrisponde alla modalità (`urls` senza `urls`, o un `url` seed in modalità `urls`); un parametro è fuori intervallo; oppure una regex `include_patterns`/`exclude_patterns` non compila. |
| `500 Internal Server Error` | Il job non ha potuto essere creato. Il messaggio include il `job_id` da citare al supporto. |

Un errore di rendering a livello di pagina **non** fa fallire la richiesta né
il job: incrementa `pages_failed`, registra l'errore della pagina nella
propria riga e il job continua. Un job `fails` soltanto quando la discovery
viene rifiutata oppure ogni pagina fallisce o viene saltata. Il riferimento
completo dei codici di stato è nella [guida ai codici di
errore](/it/docs/reference/errors.md).

---

## Limiti

| Limite | Valore |
|-------|-------|
| URL per richiesta in modalità `urls` | 1,000 |
| Lunghezza di `url` / di ogni voce `urls` | 2,048 caratteri |
| `max_pages` (tetto della discovery) | 1–1,000 |
| `max_depth` | 1–5 |
| `include_patterns` / `exclude_patterns` | 50 ciascuno |
| Lunghezza di `wait_for` | 1,024 caratteri |
| `wait_timeout_ms` | 0–60,000 ms |
| `chunk.max_words` | 32–4,000 (default 512) |
| `chunk.sentence_overlap` | 0–10 (default 1) |
| Lunghezza di `webhook_url` | 2,048 caratteri |
| Tetto di pagine per job (`MAX_PAGES_PER_JOB`) | 1,000 |
| File per richiesta `/v2/ingest/files` | 1–200 |
| Dimensione di upload per file | Dipende dal piano (Founding: 5 MB) |
| `limit` dell'elenco `GET /v2/ingest` | 1–100 (default 20) |
| Scadenza dell'`output_url` firmato | 15 minuti |
| Tentativi di consegna del webhook | 4 (iniziale + 3 retry) |
| Tolleranza di replay del webhook | 300 secondi |
| Ops mensili (condivise tra tutti gli endpoint, 1 per pagina) | 500 / 3.000 / 15.000 / 50.000 per livello; vedi [i prezzi](/it/pricing.md) |

---

## Domande frequenti

### Come faccio il crawl di un sito web per il RAG con un'API?

Invia `POST /v2/ingest` con `mode: "crawl"` e un `url` seed. La chiamata risponde `202` con un `job_id`; il worker individua le pagine, renderizza ciascuna in headless Chrome, suddivide il Markdown in chunk basati sui titoli e assembla un unico file JSONL che scarichi dall'`output_url` firmato.

### Come ingerisco file (PDF, documenti Word) per il RAG?

Invia `POST /v2/ingest/files` come `multipart/form-data` con uno o più `files`. Ogni documento viene convertito in Markdown, suddiviso in chunk basati sui titoli e assemblato nello stesso unico JSONL di un job di crawl: una sola pipeline per web e file. Sono supportati i file PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, OpenDocument e testo semplice/Markdown (fino a 200 per richiesta); l'elenco completo è sulla pagina [anything-to-markdown](/it/docs/endpoints/convert/documents/anything-to-markdown.md#supported-input-formats).

### L'output JSONL si carica direttamente in LangChain e LlamaIndex?

Sì. Ogni riga porta una stringa `content` di primo livello con un oggetto `metadata` affiancato, così lo stesso file si carica tramite LangChain `JSONLoader(content_key="content", json_lines=True)`, LlamaIndex `SimpleDirectoryReader` e qualunque import vector-DB orientato alle righe senza rimodellare nulla.

### Come suddivide il chunker le pagine in chunk per il RAG?

Suddivide sui titoli `#`, `##` e `###` con un tetto morbido `max_words` (default `512`, intervallo 32–4,000) e un `sentence_overlap` opzionale. I blocchi di codice recintati e le tabelle Markdown non vengono mai suddivisi, e ogni chunk porta con sé il suo `headings_path` completo.

### Come vengo avvisato quando un job di ingestione finisce?

Imposta `webhook_url` sul `POST` ed EnConvert invia un callback firmato in HMAC (header `X-Enconvert-Signature` e `X-Enconvert-Timestamp`) con fino a tre retry dopo il primo tentativo. Se il tuo endpoint era offline, `POST /v2/ingest/{job_id}/retry-webhook` lo rifirma e lo riconsegna.

### Perché output_url manca dalla mia risposta di ingestione?

`output_url` è presente solo quando `status` è `completed`: la risposta del `POST` è un job `queued` con il campo omesso. Interroga `GET /v2/ingest/{job_id}`, che non consuma ops e rifirma l'URL a ogni chiamata; ogni URL firmato scade dopo 15 minuti.
