---
seo_title: API d'ingestion RAG : sites et fichiers vers JSONL | EnConvert
meta_desc: Crawlez un site ou des fichiers pour le RAG avec /v2/ingest : rendu, découpage par titres et export en un JSONL pour LangChain, LlamaIndex ou une base vectorielle.
keywords: crawler un site web pour rag api, ingérer des fichiers pour rag, pdf vers jsonl pour rag, site web vers jsonl langchain llamaindex, api d'ingestion de données rag, api d'ingestion de documents pour llm, jsonl pour base de données vectorielle, langchain jsonloader
---

# API de crawl de site web pour le RAG

`POST /v2/ingest` crawle un site web pour le RAG : il transforme un site (ou
une liste explicite d'URL) en chunks prêts pour le RAG et produit **un seul
fichier JSONL** qui se charge directement dans LangChain `JSONLoader`, LlamaIndex
`SimpleDirectoryReader`, ou un import en masse vers une base vectorielle.
L'endpoint est toujours asynchrone : `POST` répond `202` avec un `job_id`, vous
interrogez `GET /v2/ingest/{job_id}` ou enregistrez un `webhook_url`, et un job
terminé renvoie une `output_url` pré-signée pour le JSONL. EnConvert effectue la
découverte, le rendu en Chrome headless, le découpage tenant compte des titres,
et l'assemblage du JSONL en un seul job.

Les **fichiers** téléversés sont ingérés par exactement le même pipeline via
[`POST /v2/ingest/files`](#ingesting-files). PDF, DOCX, PPTX, XLSX, CSV,
HTML, EPUB et bien d'autres sont convertis en Markdown, découpés en chunks et
assemblés dans le même JSONL. Une seule intégration couvre l'ingestion RAG à la
fois du web et des fichiers.

Voici l'appel utile le plus simple. Crawlez un site et découpez en chunks chaque
page qu'il découvre :

```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 réponse est l'enregistrement du job, renvoyé avec `202 Accepted`. Notez que le
statut est `queued` et que `output_url` est absent tant que le job n'est pas
terminé :

```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'ingestion est **toujours asynchrone**. Chaque page est rendue dans un vrai
navigateur, ce qui prend 10–30 secondes par URL, bien au-delà de la fenêtre de
requête de 300 secondes pour tout job non trivial. Ainsi, `POST` répond `202`
avec un `job_id`, et un worker local à la droplet traite le job en arrière-plan.
Vous interrogez `GET /v2/ingest/{job_id}` pour suivre la progression, ou
enregistrez un `webhook_url` pour être averti à la fin.

---

## Endpoints

| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/v2/ingest` | Crée un job d'ingestion **web** (liste d'URL, sitemap ou crawl). Répond `202` avec un `job_id`. |
| `POST` | `/v2/ingest/files` | Crée un job d'ingestion de **fichiers** à partir de documents téléversés (multipart). Même pipeline job + JSONL. |
| `GET` | `/v2/ingest` | Liste des jobs de ce projet, du plus récent au plus ancien, avec pagination `skip`/`limit`. |
| `GET` | `/v2/ingest/{job_id}` | Statut du cycle de vie d'un job, avec une `output_url` fraîchement signée une fois terminé. |
| `DELETE` | `/v2/ingest/{job_id}` | Annule un job. Le worker voit le statut annulé et s'arrête entre deux pages. |
| `POST` | `/v2/ingest/{job_id}/retry-webhook` | Re-signe et re-POST le webhook de fin pour un job terminé. |
| `GET` | `/v2/ingest/webhook-secret` | Révèle le secret de signature webhook du projet (canal tableau de bord). |
| `POST` | `/v2/ingest/webhook-secret/rotate` | Fait tourner le secret de signature. Les anciennes signatures cessent immédiatement d'être valides. |

**Content-Type :** `application/json` sur chaque `POST`.

---

## 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 les exemples ci-dessous.

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

Les clés publiques avec un jeton bearer JWT fonctionnent aussi, selon le même
flux que tous les autres endpoints : générez un jeton avec votre clé `pk_`, puis
envoyez-le sous forme `Authorization: Bearer <token>`. Le flux complet, y compris
le verrouillage de domaine et le rafraîchissement du jeton, est décrit dans [le
guide d'authentification](/fr/docs/authentication.md).

Chaque clé API porte une liste blanche des endpoints autorisés. Si `/v2/ingest`
n'est pas sur la liste de la clé, la requête est rejetée avec `403`. Une clé
limitée à `/v2/ingest` atteint quand même les jobs qu'elle a créés : `GET` et
`DELETE` `/v2/ingest/{job_id}` ainsi que `POST /v2/ingest/{job_id}/retry-webhook`
sont toujours autorisés pour un `job_id` (la forme `ing_…` est reconnue
explicitement). L'endpoint de liste statique et les deux routes de gestion
`webhook-secret` n'héritent **pas** de cette dérogation ; ils exigent un jeton
plus large ou limité au tableau de bord.

---

## Fonctionnement de l'ingestion

Un job passe par cinq phases, toutes durables et résistantes au redémarrage. Si
le processus worker redémarre en cours de job, le job est réinséré dans la file
au démarrage et reprend à la page où il s'était arrêté. Les pages déjà terminées
conservent leur sortie stockée et ne sont jamais re-rendues ni refacturées.

1. **Mise en file.** `POST` valide la requête, effectue une vérification rapide
   d'ops `units=1` (le plan a l'ingestion activée et de la marge dans le quota
   mensuel d'ops), insère la ligne du job et répond `202`. Rien n'est persisté
   si la barrière d'ops échoue : un `402` ne laisse aucune ligne derrière lui.
2. **Découverte.** Pour les modes `sitemap` et `crawl`, le worker effectue la
   même passe de découverte que [l'endpoint discover](/fr/docs/coming-soon/discover.md),
   plafonnée à `max_pages`, et filtre l'URL de départ contre les SSRF. Pour le
   mode `urls`, la liste explicite est dédupliquée dans l'ordre ; aucune
   découverte n'est exécutée. La taille de la découverte avant plafonnement est
   rapportée dans `pages_found` ; lorsque le site compte plus d'URL que
   `max_pages` n'en autorise, `discovery_truncated` vaut `true` et une entrée de
   `warnings` indique les deux nombres, afin que `pages_discovered` (le nombre
   mis en file) ne soit jamais confondu avec la taille du site.
3. **Rendu et découpage.** Chaque URL est rendue via le singleton Chrome headless
   partagé, le même pipeline de rendu que [l'endpoint
   perceive](/fr/docs/endpoints/perceive.md). Le HTML rendu est ensuite converti en
   fit-Markdown et découpé par le chunker tenant compte des titres. Les rendus
   s'exécutent séquentiellement, une page à la fois.
4. **Stockage intermédiaire.** Les chunks de chaque page sont écrits dans un objet
   JSONL par page dans le stockage, clé déterministe par `(project, job, url)`.
   C'est ce qui rend un redémarrage peu coûteux : un job repris réutilise les
   pages stockées au lieu de les re-rendre.
5. **Assemblage.** Une fois chaque page terminée, les objets par page sont
   concaténés dans le `v2-ingest/{job_id}.jsonl` final, les objets intermédiaires
   sont supprimés, le job passe à `completed`, et le webhook de fin signé est
   déclenché si un `webhook_url` a été défini.

Le quota d'ops est revérifié **par page** à l'intérieur du worker, pas
seulement au moment de la soumission ; chaque page aboutie facture une op. Un job `crawl` dont le nombre de pages est
inconnu au départ s'arrête proprement à votre plafond mensuel : les pages déjà
rendues sont facturées et conservées, et les pages restantes sont marquées
`skipped` plutôt que de dépasser le budget.

Les rendus d'ingestion sont **sans identifiants par conception**. Contrairement à
`/v2/perceive`, il n'accepte ni `auth`, ni `cookies`, ni `headers` personnalisés.
Aucun secret n'est persisté pour la reprise durable, de sorte que l'état du job
sur disque ne transporte jamais d'identifiants.

---

## Ingestion de fichiers {: #ingesting-files }

`POST /v2/ingest` crawle le web ; `POST /v2/ingest/files` ingère des **fichiers
téléversés** via exactement le même pipeline. Les deux créent le même job,
exécutent le même chunker tenant compte des titres et produisent le même livrable
JSONL unique, si bien qu'une seule intégration couvre l'ingestion RAG du web *et*
des fichiers.

Envoyez les documents en `multipart/form-data` dans le champ `files`. Chaque
fichier est converti en Markdown par le convertisseur [anything-to-markdown](/fr/docs/endpoints/convert/documents/anything-to-markdown.md),
puis découpé et assemblé exactement comme une page crawlée. Tous les
[formats d'entrée pris en charge](/fr/docs/endpoints/convert/documents/anything-to-markdown.md#supported-input-formats)
sont acceptés : PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, OpenDocument et texte
brut/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 réponse est le même `IngestJobResponse` que l'endpoint de crawl, avec `mode`
réglé sur `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"
}
```

Chaque fichier téléversé compte comme une « page » : il facture une op,
fait grimper `pages_processed` à mesure qu'il se termine, et est
identifié par son **nom de fichier** dans le `metadata.source_url` du JSONL. Vous
interrogez `GET /v2/ingest/{job_id}`, annulez avec `DELETE` et recevez le webhook
de fin signé exactement comme pour un job de crawl. Les fichiers ne sont stockés
que jusqu'à l'assemblage du JSONL, puis supprimés.

### Paramètres de la requête de fichiers

Envoyés en champs de formulaire multipart (pas un corps JSON) :

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `files` | file[] | -- | Un ou plusieurs documents à ingérer. 1–200 fichiers par requête ; chacun est vérifié en taille par rapport à la limite de téléversement de votre plan. |
| `max_words` | `integer` | `512` | Plafond souple de mots par chunk. 32–4,000. Les blocs de code et les tableaux à barres verticales restent atomiques. |
| `sentence_overlap` | `integer` | `1` | Phrases répétées entre deux chunks de prose consécutifs d'une même section. 0–10. |
| `webhook_url` | `string` | `null` | Callback de fin signé en HMAC, avec la même politique de signature et de réessai que [les webhooks de fin](#webhooks-de-fin) ci-dessous. |

Un type de fichier non pris en charge, un fichier vide ou une image (l'OCR n'est
pas effectué) est rejeté à la soumission avec un `400` ; un fichier dépassant la
limite de taille par fichier de votre plan est 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
```

---

## Paramètres de la requête

### Source et mode

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `mode` | `string` | `"urls"` | `urls`, `sitemap` ou `crawl`. Détermine comment l'ensemble d'URL est construit. |
| `url` | `string` | `null` | URL de départ pour le mode `sitemap`/`crawl`. Doit commencer par `http://` ou `https://`. Maximum 2,048 caractères. Requise pour ces modes ; rejetée en mode `urls`. |
| `urls` | `string[]` | `null` | URL explicites à ingérer en mode `urls`. Non vide, maximum 1,000 entrées, chacune en `http(s)` et ≤ 2,048 caractères. Requise pour le mode `urls` ; rejetée en mode `sitemap`/`crawl`. |

`url` et `urls` sont mutuellement exclusifs : envoyez exactement une source.
Le mode `urls` exige `urls` ; `sitemap` et `crawl` exigent une `url` de
départ. Envoyer le mauvais pour le mode donné produit un `422`.

### Découverte (modes sitemap / crawl)

Ceux-ci sont transmis à la passe de découverte et ignorés en mode `urls`.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `max_pages` | `integer` | `50` | Plafond des URL découvertes **et** ingérées. 1–1,000. |
| `max_depth` | `integer` | `2` | Profondeur de crawl des liens depuis le point de départ. 1–5. |
| `same_domain_only` | `boolean` | `true` | Restreint la découverte au domaine du point de départ. |
| `include_patterns` | `string[]` | `[]` | Motifs regex qu'une URL doit satisfaire pour être conservée. Maximum 50. Chacun est compilé à la soumission ; un motif invalide produit un `422`. |
| `exclude_patterns` | `string[]` | `[]` | Motifs regex qui écartent une URL correspondante. Maximum 50. |
| `respect_robots` | `boolean` | `false` | Lorsque `true`, une URL interdite par le `robots.txt` du site est ignorée. |

### Rendu

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `wait_for` | `string` | `null` | Attend après la navigation un sélecteur CSS ou une expression JS avant la capture. Maximum 1,024 caractères. |
| `wait_timeout_ms` | `integer` | `30000` | Durée maximale d'attente de `wait_for`, en millisecondes. 0–60,000. |

> **Note.** L'ingestion n'accepte **pas** `auth`, `cookies` ni `headers`.
> Si une page a besoin d'identifiants pour s'afficher, l'ingestion n'est pas le
> bon outil. Utilisez [l'endpoint perceive](/fr/docs/endpoints/perceive.md), qui offre
> toute la surface de requête authentifiée, pour cette page unique.

### Découpage (objet `chunk`)

| Parameter | Type | Default | Constraints | Description |
|-----------|------|---------|-------------|-------------|
| `max_words` | `integer` | `512` | 32–4,000 | Plafond souple de mots par chunk. Tient compte des titres. Les blocs de code et les tableaux à barres verticales restent atomiques et peuvent le dépasser. |
| `sentence_overlap` | `integer` | `1` | 0–10 | Phrases répétées entre deux chunks de prose consécutifs d'une même section. `0` désactive le chevauchement. Le chevauchement ne franchit jamais une limite de titre. |

Le chunker découpe sur les titres `#`, `##` et `###`, donc chaque chunk appartient
à exactement une section et porte son chemin de titres complet. Les titres plus
profonds (`####`–`######`) restent en ligne comme contenu. Les blocs de code
délimités et les tableaux Markdown ne sont jamais découpés, même lorsqu'un seul
bloc dépasse `max_words` ; les éléments de liste se découpent entre éléments,
jamais au milieu d'un élément.

### Webhook

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `webhook_url` | `string` | `null` | Endpoint qui reçoit le callback de fin signé en HMAC. Maximum 2,048 caractères. Le schéma est vérifié à la soumission ; le filtrage SSRF a lieu au moment de la *livraison*, pas à la soumission. |

---

## Réponse

`POST`, `GET /v2/ingest/{job_id}` et `DELETE` renvoient tous le même objet
`IngestJobResponse`.

| Field | Type | Description |
|-------|------|-------------|
| `job_id` | `string` | ID opaque (`ing_…`). Utilisez-le avec les endpoints GET/DELETE et communiquez-le au support. |
| `status` | `string` | `queued`, `discovering`, `processing`, `completed`, `failed` ou `canceled`. |
| `mode` | `string` | Le mode que vous avez soumis : `urls`, `sitemap`, `crawl` ou `files`. |
| `pages_discovered` | `integer` | Éléments effectivement mis en file par le job : URL (la liste explicite, ou le résultat de la découverte plafonné à `max_pages`), ou fichiers téléversés. `pages_processed` + `pages_failed` totalisent cette valeur une fois le job dans un statut terminal. |
| `pages_found` | `integer` | URL éligibles uniques que la découverte a produites **avant** le plafond `max_pages`. Pour les jobs `sitemap`, c'est le vrai nombre d'URL uniques du site ; pour les jobs `crawl`, c'est une borne inférieure (le crawl cesse de récupérer des pages au plafond). Absent pour les jobs `urls` et `files`. |
| `discovery_truncated` | `boolean` | `true` lorsque la découverte a trouvé plus d'URL uniques que `max_pages` n'a permis d'en mettre en file. Une entrée de `warnings` détaille les nombres ; augmentez `max_pages` pour ingérer davantage du site. |
| `pages_processed` | `integer` | URL dont le rendu → découpage → stockage s'est terminé. |
| `pages_failed` | `integer` | URL qui n'ont pas pu être rendues ou ont été ignorées (par ex. quota d'ops épuisé). |
| `total_chunks` | `integer` | Nombre total de chunks écrits sur toutes les pages terminées. Correspond au nombre de lignes du JSONL. |
| `output_url` | `string` | URL de téléchargement pré-signée pour le JSONL final. Présente uniquement lorsque `status` vaut `completed` ; expire après 15 minutes. |
| `error_message` | `string` | Défini lorsque `status` vaut `failed` (par ex. découverte rejetée, toutes les pages en échec). |
| `webhook_url` | `string` | La cible du webhook de fin enregistrée pour ce job, le cas échéant. |
| `webhook_delivered` | `boolean` | `true` une fois que le webhook de fin signé a reçu un `2xx`. |
| `created_at` | `string` | Date de création du job (UTC). |
| `completed_at` | `string` | Date à laquelle le job a atteint un statut terminal (UTC). |
| `warnings` | `string[]` | Notes non fatales, par ex. troncature de la découverte : `"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."` |

> **Note.** `POST` et les `GET`/`DELETE` par job utilisent
> `response_model_exclude_none`, de sorte que les champs encore `null` (comme
> `output_url` avant la fin) sont omis du JSON plutôt qu'envoyés comme `null`.

### Forme de l'enregistrement JSONL

Le fichier final est du JSON délimité par des sauts de ligne. Chaque ligne est 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}}
```

| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Déterministe par `(source_url, chunk_index)` : `<md5(url)[:12]>-<index:04d>`. Une réexécution produit des ids identiques. |
| `content` | `string` | Le texte du chunk récupérable. Correspond à `Document.page_content` dans LangChain. |
| `metadata.source_url` | `string` | La page d'où provient le chunk. |
| `metadata.title` | `string` | Le `<title>` de la page, à défaut le premier `<h1>`, plafonné à 512 caractères. |
| `metadata.headings_path` | `string[]` | Le chemin `h1 → h2 → h3` sous lequel se trouve le chunk. |
| `metadata.section` | `string` | Le texte du titre le plus profond (la dernière entrée de `headings_path`). |
| `metadata.word_count` | `integer` | Nombre de mots de `content`, délimités par des espaces. |
| `metadata.chunk_index` | `integer` | L'index du chunk au sein de sa page. |

Le fichier est en UTF-8, écrit avec `ensure_ascii=false`, de sorte que l'unicode
reste lisible. Comme `content` est une chaîne de premier niveau et `metadata` un
objet frère, le même fichier se charge via LangChain
`JSONLoader(content_key="content", json_lines=True)`, LlamaIndex
`SimpleDirectoryReader` et tout import vers une base vectorielle orienté ligne,
sans remise en forme.

---

## Cycle de vie du job et interrogation

Un job passe par ces états :

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

| Status | Meaning |
|--------|---------|
| `queued` | Accepté et en attente du worker. |
| `discovering` | Exécution de la passe de découverte sitemap/crawl (`sitemap`/`crawl` uniquement). |
| `processing` | Rendu et découpage des pages. `pages_processed` et `total_chunks` augmentent en direct. |
| `completed` | Le JSONL final est assemblé ; `output_url` est signée et prête. |
| `failed` | La découverte a été rejetée, ou toutes les pages ont échoué ou été ignorées. `error_message` explique. |
| `canceled` | Un `DELETE` a atteint le job avant sa fin. |

Interrogez le statut avec le `GET` par job. C'est en lecture seule : cela ne
consomme aucune op et re-signe l'`output_url` à partir de la clé d'objet
stockée à chaque appel :

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

Un `job_id` inconnu, ou appartenant à un autre projet, renvoie `404`.
L'existence n'est jamais divulguée d'un projet à l'autre.

### Lister les jobs

`GET /v2/ingest` renvoie les jobs de ce projet du plus récent au plus ancien,
avec les paramètres de requête `skip` et `limit`. `limit` vaut `20` par défaut et
est plafonné à `100`. La réponse porte un drapeau `has_more` au lieu d'un
décompte 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
}
```

Les lignes de la liste réduisent `webhook_url` à un booléen `webhook_configured`,
si bien que la liste ne renvoie jamais l'endpoint brut dans le tableau.

### Annuler un job

`DELETE /v2/ingest/{job_id}` passe le statut du job à `canceled`. Le worker lit
ce statut entre deux pages et s'arrête sans assembler de sortie. L'annulation est
idempotente et à l'épreuve des conditions de course : si l'assemblage a déjà été
validé, le `DELETE` ne correspond à rien et le job est renvoyé inchangé comme
`completed`. Un job terminé n'est jamais ramené de force à `canceled`.

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

---

## Webhooks de fin

Définissez `webhook_url` sur le `POST` et EnConvert envoie un POST signé en HMAC
lorsque le job se termine. La charge utile est un JSON compact, à clés triées :

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

La livraison réessaie jusqu'à trois fois après la première tentative, avec des
délais de back-off de 1, 4 et 16 secondes, soit quatre POST dans le pire des cas.
Chaque tentative est re-signée avec un timestamp frais, de sorte qu'une chaîne de
réessais lente ne dérive jamais au-delà de la fenêtre de fraîcheur du
consommateur. Une réponse 2xx est un succès. Un endpoint mort est enregistré
comme non-livraison et déclenche une alerte sur le tableau de bord, mais il ne
fait jamais échouer un job par ailleurs terminé.

Le `webhook_url` est **filtré contre les SSRF au moment de la livraison**, pas à
la soumission. Une URL qui se résout en adresse privée, de loopback ou de
métadonnées est stockée de façon inerte et n'est rejetée que lorsque EnConvert
tente d'y faire un POST.

### Vérifier la signature

Chaque livraison porte deux en-têtes :

| Header | Value |
|--------|-------|
| `X-Enconvert-Signature` | `sha256=<hex>`, le HMAC-SHA256 de `<timestamp>.<raw body>`. |
| `X-Enconvert-Timestamp` | Le timestamp en secondes unix lié à la signature. |

L'entrée de signature est le timestamp, un `.` littéral, puis le corps brut de la
requête. Lier le timestamp au MAC signifie qu'un consommateur qui rejette les
timestamps périmés obtient gratuitement une protection contre le rejeu. La
fenêtre de fraîcheur par défaut est de **300 secondes**. Vérifiez dans votre
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)
```

### Gérer le secret de signature

`GET /v2/ingest/webhook-secret` révèle le secret du projet (en le créant au
premier appel) ainsi que les noms d'en-têtes et la tolérance dont votre
consommateur a besoin. Il est **sensible** et n'est exposé que via le canal
authentifié du tableau de bord :

```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` émet un nouveau secret et met `rotated` à
`true`. Toute signature calculée avec le secret précédent cesse d'être valide dès
que la rotation est validée. Faites tourner le secret après une fuite suspectée,
puis mettez à jour votre consommateur.

### Re-livrer un webhook

Si votre endpoint était hors service à la fin du job,
`POST /v2/ingest/{job_id}/retry-webhook` re-signe et re-POST avec la même
politique de réessai :

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

Il renvoie `404` pour un `job_id` inconnu ou étranger, `400` lorsqu'aucun
`webhook_url` n'est configuré (ou que l'URL stockée se résout désormais en adresse
privée/interne), et `409` lorsque le job n'a pas atteint `completed`.

---

## Exemples de code

### curl : liste d'URL explicite

```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 avec découpage et 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 : soumettre, interroger, télécharger

```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 : soumettre et interroger

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

---

## Réponses d'erreur

| Status | Condition |
|--------|-----------|
| `202 Accepted` | Le job a été créé et mis en file. C'est le résultat normal d'un `POST`. |
| `401 Unauthorized` | Clé API / jeton JWT manquant ou invalide. |
| `402 Payment Required` | L'ingestion n'est pas dans votre plan actuel, ou votre quota mensuel d'ops est épuisé. |
| `403 Forbidden` | `/v2/ingest` n'est pas dans les endpoints autorisés de la clé API. |
| `404 Not Found` | `job_id` inconnu, ou appartenant à un autre projet. |
| `409 Conflict` | `retry-webhook` appelé sur un job qui n'a pas atteint `completed`. |
| `400 Bad Request` | `retry-webhook` appelé sans `webhook_url` configuré, ou son URL stockée se résout désormais en adresse privée/interne. |
| `422 Unprocessable Entity` | La source ne correspond pas au mode (`urls` sans `urls`, ou une `url` de départ en mode `urls`) ; un paramètre est hors plage ; ou une regex `include_patterns`/`exclude_patterns` ne compile pas. |
| `500 Internal Server Error` | Le job n'a pas pu être créé. Le message inclut le `job_id` à communiquer au support. |

Un échec de rendu au niveau d'une page ne fait **pas** échouer la requête ni le
job. Il incrémente `pages_failed`, place l'erreur de la page dans sa propre
ligne, et le job continue. Un job n'échoue (`fails`) que lorsque la découverte
est rejetée ou que toutes les pages échouent ou sont ignorées. 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

| Limit | Value |
|-------|-------|
| URL par requête en mode `urls` | 1,000 |
| Longueur de `url` / de chaque entrée `urls` | 2,048 characters |
| `max_pages` (plafond de découverte) | 1–1,000 |
| `max_depth` | 1–5 |
| `include_patterns` / `exclude_patterns` | 50 each |
| Longueur de `wait_for` | 1,024 characters |
| `wait_timeout_ms` | 0–60,000 ms |
| `chunk.max_words` | 32–4,000 (default 512) |
| `chunk.sentence_overlap` | 0–10 (default 1) |
| Longueur de `webhook_url` | 2,048 characters |
| Plafond de pages par job (`MAX_PAGES_PER_JOB`) | 1,000 |
| Fichiers par requête `/v2/ingest/files` | 1–200 |
| Taille de téléversement par fichier | Selon le plan (Founding : 5 MB) |
| `limit` de la liste `GET /v2/ingest` | 1–100 (default 20) |
| Expiration de l'`output_url` signée | 15 minutes |
| Tentatives de livraison du webhook | 4 (initial + 3 retries) |
| Tolérance de rejeu du webhook | 300 seconds |
| Ops mensuelles (partagées entre tous les endpoints, 1 par page) | 500 / 3 000 / 15 000 / 50 000 selon le palier ; voir [les tarifs](/fr/pricing.md) |

---

## Questions fréquentes

### Comment crawler un site web pour le RAG avec une API ?

Envoyez `POST /v2/ingest` avec `mode: "crawl"` et une `url` de départ. L'appel répond `202` avec un `job_id` ; le worker découvre les pages, rend chacune en Chrome headless, découpe le Markdown en tenant compte des titres, et assemble un fichier JSONL que vous téléchargez depuis l'`output_url` signée.

### Comment ingérer des fichiers (PDF, documents Word) pour le RAG ?

Envoyez `POST /v2/ingest/files` en `multipart/form-data` avec un ou plusieurs `files`. Chaque document est converti en Markdown, découpé en tenant compte des titres, et assemblé dans le même JSONL unique qu'un job de crawl, si bien qu'un seul pipeline couvre le web et les fichiers. Les fichiers PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, OpenDocument et texte brut/Markdown sont pris en charge (jusqu'à 200 par requête) ; la liste complète est sur la page [anything-to-markdown](/fr/docs/endpoints/convert/documents/anything-to-markdown.md#supported-input-formats).

### La sortie JSONL se charge-t-elle directement dans LangChain et LlamaIndex ?

Oui. Chaque ligne porte une chaîne `content` de premier niveau avec un objet `metadata` frère, de sorte que le même fichier se charge via LangChain `JSONLoader(content_key="content", json_lines=True)`, LlamaIndex `SimpleDirectoryReader` et tout import vers une base vectorielle orienté ligne, sans remise en forme.

### Comment le chunker découpe-t-il les pages en chunks RAG ?

Il découpe sur les titres `#`, `##` et `###` avec un plafond souple `max_words` (défaut `512`, plage 32–4,000) et un `sentence_overlap` optionnel. Les blocs de code délimités et les tableaux Markdown ne sont jamais découpés, et chaque chunk porte son `headings_path` complet.

### Comment être notifié quand un job d'ingestion se termine ?

Définissez `webhook_url` sur le `POST` et EnConvert envoie un callback signé en HMAC (en-têtes `X-Enconvert-Signature` et `X-Enconvert-Timestamp`) avec jusqu'à trois réessais après la première tentative. Si votre endpoint était hors service, `POST /v2/ingest/{job_id}/retry-webhook` le re-signe et le re-livre.

### Pourquoi output_url manque-t-il dans ma réponse d'ingestion ?

`output_url` n'est présent que lorsque `status` vaut `completed`. La réponse au `POST` est un job `queued` avec le champ omis. Interrogez `GET /v2/ingest/{job_id}`, qui ne consomme aucune op et re-signe l'URL à chaque appel ; chaque URL signée expire après 15 minutes.
