---
seo_title: RAG-Ingestion-API: Website & Dateien zu JSONL | EnConvert
meta_desc: Crawle eine Website oder lade Dateien für RAG per /v2/ingest hoch: rendern, überschriftenbasiert chunken und eine JSONL-Datei für LangChain und LlamaIndex erzeugen.
keywords: Website crawlen für RAG API, Dateien für RAG ingesten API, PDF zu JSONL für RAG, Website zu JSONL LangChain LlamaIndex, RAG Daten-Ingestion API, Dokument-Ingestion API für LLM, JSONL für Vektordatenbank, LangChain JSONLoader
---

# Website für RAG crawlen – API

`POST /v2/ingest` crawlt eine Website für RAG: Es verwandelt eine Site
(oder eine explizite Liste von URLs) in RAG-fertige Chunks und erzeugt
**eine JSONL-Datei**, die sich direkt in LangChain `JSONLoader`, LlamaIndex
`SimpleDirectoryReader` oder einen Vektor-DB-Bulk-Import lädt. Der Endpunkt
ist immer asynchron: `POST` antwortet mit `202` und einer `job_id`, du
pollst `GET /v2/ingest/{job_id}` oder registrierst eine `webhook_url`, und
ein abgeschlossener Job liefert eine vorsignierte `output_url` für die JSONL
zurück. EnConvert übernimmt die Discovery, den Headless-Chrome-Render, das
überschriftenbasierte Chunking und die JSONL-Assemblierung in einem Job.

Hochgeladene **Dateien** durchlaufen dieselbe Pipeline über
[`POST /v2/ingest/files`](#ingesting-files). PDF, DOCX, PPTX, XLSX, CSV,
HTML, EPUB und mehr werden zu Markdown konvertiert, gechunkt und in dieselbe
JSONL assembliert. Eine Integration deckt sowohl Web- als auch Datei-RAG-Ingestion ab.

Hier ist der kleinste nützliche Aufruf. Crawle eine Site und chunke jede
Seite, die dabei gefunden wird:

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

Die Antwort ist der Job-Datensatz, zurückgegeben mit `202 Accepted`. Beachte,
dass der Status `queued` ist und `output_url` fehlt, bis der Job
abgeschlossen ist:

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

Ingest ist **immer asynchron**. Jede Seite rendert in einem echten Browser,
was 10–30 Sekunden pro URL dauert, weit jenseits des 300-Sekunden-Request-Fensters
für jeden nicht-trivialen Job. Also antwortet `POST` mit `202` und einer
`job_id`, und ein droplet-lokaler Worker arbeitet den Job außerhalb des
Requests ab. Du pollst `GET /v2/ingest/{job_id}` für den Fortschritt oder
registrierst eine `webhook_url`, um benachrichtigt zu werden, wenn er fertig ist.

---

## Endpunkte

| Methode | Pfad | Zweck |
|--------|------|---------|
| `POST` | `/v2/ingest` | Erstellt einen **Web**-Ingest-Job (URL-Liste, Sitemap oder Crawl). Antwortet mit `202` und einer `job_id`. |
| `POST` | `/v2/ingest/files` | Erstellt einen **Datei**-Ingest-Job aus hochgeladenen Dokumenten (Multipart). Gleiche Job- + JSONL-Pipeline. |
| `GET` | `/v2/ingest` | Liste der Jobs dieses Projekts, neueste zuerst, mit `skip`/`limit`-Paging. |
| `GET` | `/v2/ingest/{job_id}` | Lifecycle-Status eines Jobs, mit einer frisch signierten `output_url`, sobald abgeschlossen. |
| `DELETE` | `/v2/ingest/{job_id}` | Bricht einen Job ab. Der Worker erkennt den abgebrochenen Status und stoppt zwischen den Seiten. |
| `POST` | `/v2/ingest/{job_id}/retry-webhook` | Signiert den Abschluss-Webhook neu und POSTet ihn erneut für einen abgeschlossenen Job. |
| `GET` | `/v2/ingest/webhook-secret` | Zeigt das Webhook-Signaturgeheimnis des Projekts an (Dashboard-Kanal). |
| `POST` | `/v2/ingest/webhook-secret/rotate` | Rotiert das Signaturgeheimnis. Alte Signaturen verifizieren sofort nicht mehr. |

**Content-Type:** `application/json` bei jedem `POST`.

---

## Authentifizierung

Authentifiziere dich mit einem privaten Schlüssel im `X-API-Key`-Header für
Server-zu-Server-Aufrufe. Diesen Weg nutzen die Beispiele unten.

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

Öffentliche Schlüssel mit einem JWT-Bearer-Token funktionieren ebenfalls, mit
demselben Flow wie bei jedem anderen Endpunkt: Generiere ein Token mit deinem
`pk_`-Schlüssel und sende es dann als `Authorization: Bearer <token>`. Der
vollständige Flow, inklusive Domain-Locking und Token-Refresh, steht in
[der Authentifizierungs-Anleitung](/de/docs/authentication.md).

Jeder API-Schlüssel trägt eine Allowlist erlaubter Endpunkte. Wenn
`/v2/ingest` nicht auf der Liste des Schlüssels steht, wird der Request mit
`403` abgelehnt. Ein auf `/v2/ingest` beschränkter Schlüssel erreicht dennoch
die von ihm erstellten Jobs: `GET` und `DELETE`
`/v2/ingest/{job_id}` sowie `POST /v2/ingest/{job_id}/retry-webhook` sind
für eine `job_id` immer erlaubt (die `ing_…`-Form wird explizit abgeglichen).
Der statische Listen-Endpunkt und die beiden `webhook-secret`-Management-Routen
erben diesen Bypass **nicht**; sie erfordern ein breiteres oder
Dashboard-scoped Token.

---

## Wie Ingest funktioniert

Ein Job durchläuft fünf Phasen, alle dauerhaft und neustartsicher. Wenn der
Worker-Prozess mitten im Job neu startet, wird der Job beim Boot erneut
eingereiht und setzt bei der Seite fort, an der er gestoppt hat. Bereits
abgeschlossene Seiten behalten ihre gestagte Ausgabe und werden nie erneut
gerendert oder erneut berechnet.

1. **Einreihen.** `POST` validiert den Request, führt eine schnelle
   `units=1`-Ops-Prüfung durch (Plan hat Ingest aktiviert und noch
   Spielraum im monatlichen Ops-Kontingent), fügt die Job-Zeile ein und
   antwortet mit `202`. Nichts wird persistiert, wenn das Ops-Gate
   fehlschlägt: Ein `402` hinterlässt null Zeilen.
2. **Entdecken.** Für den `sitemap`- und `crawl`-Modus führt der Worker
   denselben Discovery-Durchlauf aus wie [der Discover-Endpunkt](/de/docs/coming-soon/discover.md),
   begrenzt auf `max_pages`, und SSRF-prüft die Seed-URL. Für den `urls`-Modus
   wird die explizite Liste der Reihe nach dedupliziert; es läuft keine Discovery.
   Die Discovery-Größe vor der Begrenzung wird als `pages_found` gemeldet;
   hat die Site mehr URLs, als `max_pages` zuließ, ist `discovery_truncated`
   `true` und ein `warnings`-Eintrag nennt beide Zahlen, sodass
   `pages_discovered` (die eingereihte Anzahl) nie mit der Größe der Site
   verwechselt wird.
3. **Rendern und chunken.** Jede URL rendert über das gemeinsame
   Headless-Chrome-Singleton, dieselbe Render-Pipeline hinter
   [dem Perceive-Endpunkt](/de/docs/endpoints/perceive.md). Das gerenderte HTML wird dann
   zu fit-Markdown konvertiert und vom überschriftenbasierten Chunker zerteilt.
   Renders laufen sequenziell, eine Seite nach der anderen.
4. **Zwischenspeichern.** Die Chunks jeder Seite werden als seitenweises
   JSONL-Objekt im Storage abgelegt, mit `(project, job, url)` als
   deterministischem Schlüssel. Das macht einen Neustart günstig: Ein
   fortgesetzter Job verwendet zwischengespeicherte Seiten wieder, statt sie
   neu zu rendern.
5. **Assemblieren.** Sobald jede Seite fertig ist, werden die seitenweisen
   Objekte zur finalen `v2-ingest/{job_id}.jsonl` zusammengefügt, die
   Staging-Objekte werden gelöscht, der Job springt auf `completed`, und der
   signierte Abschluss-Webhook feuert, falls eine `webhook_url` gesetzt war.

Das Ops-Kontingent wird **pro Seite** im Worker erneut geprüft, nicht nur
beim Absenden; jede abgeschlossene Seite berechnet eine Op. Ein `crawl`-Job, dessen Seitenzahl vorab unbekannt ist, stoppt
sauber an deinem Monatslimit: Die bereits gerenderten Seiten werden berechnet
und behalten, und die verbleibenden Seiten werden als `skipped` markiert,
statt zu viel auszugeben.

Ingest-Renders sind **per Design ohne Anmeldedaten**. Anders als
`/v2/perceive` akzeptiert es kein `auth`, keine `cookies` und keine
benutzerdefinierten `headers`. Nichts Geheimes wird für den dauerhaften
Resume persistiert, sodass der Job-Status auf der Festplatte nie Anmeldedaten
trägt.

---

## Dateien ingesten {: #ingesting-files }

`POST /v2/ingest` crawlt das Web; `POST /v2/ingest/files` ingested
**hochgeladene Dateien** durch dieselbe Pipeline. Beide erstellen denselben
Job, führen denselben überschriftenbasierten Chunker aus und erzeugen dasselbe
einzelne JSONL-Ergebnis, sodass eine Integration Web- *und*
Datei-RAG-Ingestion abdeckt.

Sende die Dokumente als `multipart/form-data` im `files`-Feld. Jede Datei wird
vom [anything-to-markdown](/de/docs/endpoints/convert/documents/anything-to-markdown.md)-Konverter zu Markdown
konvertiert, dann gechunkt und genau wie eine gecrawlte Seite assembliert.
Jedes [unterstützte Eingabeformat](/de/docs/endpoints/convert/documents/anything-to-markdown.md#supported-input-formats)
wird akzeptiert: PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, OpenDocument und
reiner Text/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"
```

Die Antwort ist dieselbe `IngestJobResponse` wie beim Crawl-Endpunkt, mit
`mode` auf `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"
}
```

Jede hochgeladene Datei zählt als eine „Seite": Sie berechnet eine Op,
erhöht `pages_processed`, sobald sie fertig ist, und
wird durch ihren **Dateinamen** in der JSONL-`metadata.source_url`
gekennzeichnet. Du pollst `GET /v2/ingest/{job_id}`, brichst mit `DELETE` ab
und erhältst den signierten Abschluss-Webhook genau wie bei einem Crawl-Job.
Dateien werden nur gespeichert, bis die JSONL assembliert ist, dann gelöscht.

### Datei-Request-Parameter

Als Multipart-Formularfelder gesendet (kein JSON-Body):

| Feld | Typ | Standard | Beschreibung |
|-------|------|---------|-------------|
| `files` | file[] | -- | Ein oder mehrere Dokumente zum Ingesten. 1–200 Dateien pro Request; jede wird gegen das Upload-Limit deines Plans größengeprüft. |
| `max_words` | `integer` | `512` | Weiches Limit für Wörter pro Chunk. 32–4,000. Codeblöcke und Pipe-Tabellen bleiben atomar. |
| `sentence_overlap` | `integer` | `1` | Sätze, die zwischen aufeinanderfolgenden Prosa-Chunks desselben Abschnitts wiederholt werden. 0–10. |
| `webhook_url` | `string` | `null` | HMAC-signierter Abschluss-Callback mit derselben Signatur- und Retry-Policy wie [Abschluss-Webhooks](#abschluss-webhooks) unten. |

Ein nicht unterstützter Dateityp, eine leere Datei oder ein Bild (es wird kein
OCR durchgeführt) wird beim Absenden mit einem `400` abgelehnt; eine Datei über
dem Größenlimit pro Datei deines Plans ergibt ein `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
```

---

## Request-Parameter

### Quelle und Modus

| Parameter | Typ | Standard | Beschreibung |
|-----------|------|---------|-------------|
| `mode` | `string` | `"urls"` | `urls`, `sitemap` oder `crawl`. Legt fest, wie das URL-Set aufgebaut wird. |
| `url` | `string` | `null` | Seed-URL für den `sitemap`/`crawl`-Modus. Muss mit `http://` oder `https://` beginnen. Max. 2,048 Zeichen. Für diese Modi erforderlich; im `urls`-Modus abgelehnt. |
| `urls` | `string[]` | `null` | Explizite URLs zum Ingesten im `urls`-Modus. Nicht leer, max. 1,000 Einträge, jeweils `http(s)` und ≤ 2,048 Zeichen. Für den `urls`-Modus erforderlich; im `sitemap`/`crawl`-Modus abgelehnt. |

`url` und `urls` schließen sich gegenseitig aus: Sende genau eine Quelle. Der
`urls`-Modus erfordert `urls`; `sitemap` und `crawl` erfordern eine
Seed-`url`. Das Falsche für den Modus zu senden ergibt ein `422`.

### Discovery (sitemap-/crawl-Modus)

Diese werden an den Discovery-Durchlauf weitergereicht und im `urls`-Modus
ignoriert.

| Parameter | Typ | Standard | Beschreibung |
|-----------|------|---------|-------------|
| `max_pages` | `integer` | `50` | Limit für entdeckte **und** ingestete URLs. 1–1,000. |
| `max_depth` | `integer` | `2` | Crawl-Linktiefe ab dem Seed. 1–5. |
| `same_domain_only` | `boolean` | `true` | Beschränkt die Discovery auf die Domain des Seeds. |
| `include_patterns` | `string[]` | `[]` | Regex-Muster, die eine URL erfüllen muss, um behalten zu werden. Max. 50. Jedes wird beim Absenden kompiliert; ein fehlerhaftes Muster ergibt ein `422`. |
| `exclude_patterns` | `string[]` | `[]` | Regex-Muster, die eine passende URL verwerfen. Max. 50. |
| `respect_robots` | `boolean` | `false` | Bei `true` wird eine durch die `robots.txt` der Site verbotene URL übersprungen. |

### Rendering

| Parameter | Typ | Standard | Beschreibung |
|-----------|------|---------|-------------|
| `wait_for` | `string` | `null` | Wartet nach der Navigation auf einen CSS-Selektor oder JS-Ausdruck vor der Erfassung. Max. 1,024 Zeichen. |
| `wait_timeout_ms` | `integer` | `30000` | Wie lange `wait_for` warten darf, in Millisekunden. 0–60,000. |

> **Hinweis.** Ingest akzeptiert **kein** `auth`, keine `cookies` und keine
> `headers`. Wenn eine Seite zum Rendern Anmeldedaten braucht, ist Ingest das
> falsche Werkzeug. Nutze für diese eine Seite
> [den Perceive-Endpunkt](/de/docs/endpoints/perceive.md), der die vollständige Oberfläche
> für authentifizierte Requests bietet.

### Chunking (`chunk`-Objekt)

| Parameter | Typ | Standard | Constraints | Beschreibung |
|-----------|------|---------|-------------|-------------|
| `max_words` | `integer` | `512` | 32–4,000 | Weiches Limit für Wörter pro Chunk. Überschriftenbasiert. Codeblöcke und Pipe-Tabellen bleiben atomar und dürfen dies überschreiten. |
| `sentence_overlap` | `integer` | `1` | 0–10 | Sätze, die zwischen aufeinanderfolgenden Prosa-Chunks desselben Abschnitts wiederholt werden. `0` deaktiviert das Overlap. Overlap überschreitet nie eine Überschriftengrenze. |

Der Chunker teilt an `#`-, `##`- und `###`-Überschriften, sodass jeder Chunk zu
genau einem Abschnitt gehört und seinen vollständigen Überschriftenpfad trägt.
Tiefere Überschriften (`####`–`######`) bleiben inline als Inhalt. Fenced
Codeblöcke und Markdown-Tabellen werden nie geteilt, selbst wenn ein einzelner
Block `max_words` überschreitet; Listeneinträge werden zwischen Einträgen
geteilt, nie mitten im Eintrag.

### Webhook

| Parameter | Typ | Standard | Beschreibung |
|-----------|------|---------|-------------|
| `webhook_url` | `string` | `null` | Endpunkt, der den HMAC-signierten Abschluss-Callback empfängt. Max. 2,048 Zeichen. Schema-geprüft beim Absenden; SSRF-geprüft zum *Zeitpunkt der Zustellung*, nicht beim Absenden. |

---

## Response

`POST`, `GET /v2/ingest/{job_id}` und `DELETE` geben alle dasselbe
`IngestJobResponse`-Objekt zurück.

| Feld | Typ | Beschreibung |
|-------|------|-------------|
| `job_id` | `string` | Opake ID (`ing_…`). Verwende sie mit den GET/DELETE-Endpunkten und nenne sie dem Support. |
| `status` | `string` | `queued`, `discovering`, `processing`, `completed`, `failed` oder `canceled`. |
| `mode` | `string` | Der von dir übermittelte Modus: `urls`, `sitemap`, `crawl` oder `files`. |
| `pages_discovered` | `integer` | Elemente, die der Job tatsächlich eingereiht hat: URLs (die explizite Liste oder das auf `max_pages` begrenzte Discovery-Ergebnis) oder hochgeladene Dateien. `pages_processed` + `pages_failed` summieren sich darauf, sobald der Job terminal ist. |
| `pages_found` | `integer` | Eindeutige zulässige URLs, die die Discovery **vor** der `max_pages`-Begrenzung ergab. Für `sitemap`-Jobs ist dies die tatsächliche eindeutige Anzahl der Site; für `crawl`-Jobs ist es eine Untergrenze (der Crawl stoppt das Abrufen an der Begrenzung). Fehlt bei `urls`- und `files`-Jobs. |
| `discovery_truncated` | `boolean` | `true`, wenn die Discovery mehr eindeutige URLs fand, als `max_pages` den Job einreihen ließ. Ein `warnings`-Eintrag nennt die Zahlen; erhöhe `max_pages`, um mehr von der Site zu ingesten. |
| `pages_processed` | `integer` | URLs, deren render → chunk → stage abgeschlossen wurde. |
| `pages_failed` | `integer` | URLs, die nicht gerendert werden konnten oder übersprungen wurden (z. B. Ops-Kontingent erschöpft). |
| `total_chunks` | `integer` | Insgesamt geschriebene Chunks über alle abgeschlossenen Seiten. Entspricht der JSONL-Zeilenanzahl. |
| `output_url` | `string` | Vorsignierte Download-URL für die finale JSONL. Nur vorhanden, sobald `status` `completed` ist; läuft nach 15 Minuten ab. |
| `error_message` | `string` | Gesetzt, wenn `status` `failed` ist (z. B. Discovery abgelehnt, alle Seiten fehlgeschlagen). |
| `webhook_url` | `string` | Das für diesen Job registrierte Abschluss-Webhook-Ziel, falls vorhanden. |
| `webhook_delivered` | `boolean` | `true`, sobald der signierte Abschluss-Webhook ein `2xx` erhalten hat. |
| `created_at` | `string` | Wann der Job erstellt wurde (UTC). |
| `completed_at` | `string` | Wann der Job einen terminalen Status erreichte (UTC). |
| `warnings` | `string[]` | Nicht fatale Hinweise, z. B. Discovery-Trunkierung: `"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."` |

> **Hinweis.** `POST` und das jobbezogene `GET`/`DELETE` verwenden
> `response_model_exclude_none`, sodass Felder, die noch `null` sind (wie
> `output_url` vor dem Abschluss), aus dem JSON weggelassen statt als `null`
> gesendet werden.

### Die Form des JSONL-Datensatzes

Die finale Datei ist zeilengetrenntes JSON. Jede Zeile ist ein 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}}
```

| Feld | Typ | Beschreibung |
|-------|------|-------------|
| `id` | `string` | Deterministisch pro `(source_url, chunk_index)`: `<md5(url)[:12]>-<index:04d>`. Ein erneuter Lauf erzeugt identische IDs. |
| `content` | `string` | Der abrufbare Chunk-Text. Wird in LangChain auf `Document.page_content` gemappt. |
| `metadata.source_url` | `string` | Die Seite, aus der der Chunk stammt. |
| `metadata.title` | `string` | `<title>` der Seite, ersatzweise das erste `<h1>`, begrenzt auf 512 Zeichen. |
| `metadata.headings_path` | `string[]` | Der `h1 → h2 → h3`-Pfad, unter dem der Chunk liegt. |
| `metadata.section` | `string` | Der innerste Überschriftentext (der letzte Eintrag von `headings_path`). |
| `metadata.word_count` | `integer` | Durch Whitespace getrennte Wortanzahl von `content`. |
| `metadata.chunk_index` | `integer` | Der Index des Chunks innerhalb seiner Seite. |

Die Datei ist UTF-8, geschrieben mit `ensure_ascii=false`, sodass Unicode
lesbar bleibt. Weil `content` ein String auf oberster Ebene und `metadata` ein
Geschwister-Objekt ist, lädt dieselbe Datei über LangChain
`JSONLoader(content_key="content", json_lines=True)`, LlamaIndex
`SimpleDirectoryReader` und jeden zeilenorientierten Vektor-DB-Import ohne
Umformen.

---

## Job-Lifecycle und Polling

Ein Job durchläuft diese Zustände:

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

| Status | Bedeutung |
|--------|---------|
| `queued` | Angenommen und wartet auf den Worker. |
| `discovering` | Führt den sitemap-/crawl-Discovery-Durchlauf aus (nur `sitemap`/`crawl`). |
| `processing` | Rendert und chunkt Seiten. `pages_processed` und `total_chunks` steigen live. |
| `completed` | Die finale JSONL ist assembliert; `output_url` ist signiert und bereit. |
| `failed` | Discovery wurde abgelehnt, oder jede Seite ist fehlgeschlagen oder wurde übersprungen. `error_message` erklärt es. |
| `canceled` | Ein `DELETE` erreichte den Job, bevor er fertig war. |

Polle den Status mit dem jobbezogenen `GET`. Das ist read-only: Es verbraucht
keine Ops und signiert die `output_url` bei jedem Aufruf neu aus dem
gespeicherten Objektschlüssel:

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

Eine unbekannte `job_id` oder eine, die zu einem anderen Projekt gehört, gibt
`404` zurück. Die Existenz wird nie projektübergreifend preisgegeben.

### Jobs auflisten

`GET /v2/ingest` gibt die Jobs dieses Projekts neueste zuerst zurück, mit den
Query-Parametern `skip` und `limit`. `limit` ist standardmäßig `20` und auf
`100` begrenzt. Die Antwort trägt ein `has_more`-Flag statt einer Gesamtanzahl:

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

Die Listenzeilen reduzieren `webhook_url` auf einen
`webhook_configured`-Boolean, sodass die Liste den rohen Endpunkt nie in die
Tabelle zurückgibt.

### Einen Job abbrechen

`DELETE /v2/ingest/{job_id}` setzt den Status des Jobs auf `canceled`. Der
Worker liest diesen Status zwischen den Seiten und stoppt, ohne die Ausgabe zu
assemblieren. Der Abbruch ist idempotent und race-sicher: Wenn die
Assemblierung bereits committet wurde, trifft das `DELETE` nichts und der Job
wird unverändert als `completed` zurückgegeben. Ein fertiger Job wird nie auf
`canceled` zurückgesetzt.

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

---

## Abschluss-Webhooks

Setze `webhook_url` beim `POST`, und EnConvert sendet einen HMAC-signierten
POST, wenn der Job abgeschlossen ist. Die Payload ist kompaktes, nach
Schlüsseln sortiertes JSON:

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

Die Zustellung wird nach dem ersten Versuch bis zu dreimal wiederholt, mit
Back-off-Verzögerungen von 1, 4 und 16 Sekunden, also vier POSTs im
schlimmsten Fall. Jeder Versuch wird mit einem frischen Timestamp neu
signiert, sodass eine langsame Retry-Kette nie über das Frische-Fenster des
Consumers hinausdriftet. Eine 2xx-Antwort ist ein Erfolg. Ein toter Endpunkt
wird als Nicht-Zustellung erfasst und löst einen Dashboard-Alert aus, lässt
einen ansonsten abgeschlossenen Job aber nie scheitern.

Die `webhook_url` wird **zum Zeitpunkt der Zustellung SSRF-geprüft**, nicht
beim Absenden. Eine URL, die zu einer privaten, Loopback- oder
Metadaten-Adresse auflöst, wird inert gespeichert und erst abgelehnt, wenn
EnConvert versucht, dorthin zu POSTen.

### Die Signatur verifizieren

Jede Zustellung trägt zwei Header:

| Header | Wert |
|--------|-------|
| `X-Enconvert-Signature` | `sha256=<hex>`, das HMAC-SHA256 von `<timestamp>.<raw body>`. |
| `X-Enconvert-Timestamp` | Der in die Signatur eingebundene Unix-Sekunden-Timestamp. |

Die Signatureingabe ist der Timestamp, ein literaler `.`, dann der rohe
Request-Body. Das Einbinden des Timestamps in den MAC bedeutet, dass ein
Consumer, der veraltete Timestamps ablehnt, Replay-Schutz gratis bekommt. Das
Standard-Frische-Fenster beträgt **300 Sekunden**. Verifiziere in deinem
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)
```

### Das Signaturgeheimnis verwalten

`GET /v2/ingest/webhook-secret` zeigt das Geheimnis des Projekts an (erstellt
es beim ersten Aufruf) zusammen mit den Header-Namen und der Toleranz, die dein
Consumer braucht. Es ist **sensibel** und wird nur über den authentifizierten
Dashboard-Kanal offengelegt:

```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` stellt ein neues Geheimnis aus und
setzt `rotated` auf `true`. Jede mit dem vorherigen Geheimnis berechnete
Signatur verifiziert in dem Moment nicht mehr, in dem die Rotation committet.
Rotiere nach einem vermuteten Leak und aktualisiere dann deinen Consumer.

### Einen Webhook erneut zustellen

Wenn dein Endpunkt beim Abschluss des Jobs ausgefallen war, signiert
`POST /v2/ingest/{job_id}/retry-webhook` neu und POSTet erneut mit derselben
Retry-Policy:

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

Es gibt `404` für eine unbekannte oder fremde `job_id` zurück, `400`, wenn
keine `webhook_url` konfiguriert ist (oder die gespeicherte URL nun zu einer
privaten/internen Adresse auflöst), und `409`, wenn der Job `completed` nicht
erreicht hat.

---

## Codebeispiele

### curl: explizite URL-Liste

```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 mit Chunking und einem 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: absenden, pollen, herunterladen

```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: absenden und pollen

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

---

## Fehlerantworten

| Status | Bedingung |
|--------|-----------|
| `202 Accepted` | Der Job wurde erstellt und eingereiht. Das ist das normale `POST`-Ergebnis. |
| `401 Unauthorized` | Fehlender oder ungültiger API-Schlüssel / JWT-Token. |
| `402 Payment Required` | Ingest ist nicht in deinem aktuellen Plan enthalten, oder dein monatliches Ops-Kontingent ist erschöpft. |
| `403 Forbidden` | `/v2/ingest` ist nicht in den erlaubten Endpunkten des API-Schlüssels. |
| `404 Not Found` | Unbekannte `job_id` oder eine, die einem anderen Projekt gehört. |
| `409 Conflict` | `retry-webhook` auf einem Job aufgerufen, der `completed` nicht erreicht hat. |
| `400 Bad Request` | `retry-webhook` aufgerufen, ohne dass eine `webhook_url` konfiguriert ist, oder ihre gespeicherte URL löst nun zu einer privaten/internen Adresse auf. |
| `422 Unprocessable Entity` | Quelle passt nicht zum Modus (`urls` ohne `urls` oder eine Seed-`url` im `urls`-Modus); ein Parameter ist außerhalb des Bereichs; oder ein `include_patterns`/`exclude_patterns`-Regex kompiliert nicht. |
| `500 Internal Server Error` | Der Job konnte nicht erstellt werden. Die Nachricht enthält die `job_id`, die du dem Support nennst. |

Ein Render-Fehler auf Seitenebene lässt **weder** den Request noch den Job
scheitern. Er erhöht `pages_failed`, legt den Fehler der Seite in ihrer
eigenen Zeile ab, und der Job läuft weiter. Ein Job `fails` nur, wenn die
Discovery abgelehnt wird oder jede Seite fehlschlägt oder übersprungen wird.
Die vollständige Statuscode-Referenz steht in
[der Fehlercode-Anleitung](/de/docs/reference/errors.md).

---

## Limits

| Limit | Wert |
|-------|-------|
| URLs pro Request im `urls`-Modus | 1,000 |
| Länge von `url` / jedem `urls`-Eintrag | 2,048 characters |
| `max_pages` (Discovery-Limit) | 1–1,000 |
| `max_depth` | 1–5 |
| `include_patterns` / `exclude_patterns` | 50 each |
| Länge von `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) |
| Länge von `webhook_url` | 2,048 characters |
| Seitenobergrenze pro Job (`MAX_PAGES_PER_JOB`) | 1,000 |
| Dateien pro `/v2/ingest/files`-Request | 1–200 |
| Upload-Größe pro Datei | Planabhängig (Founding: 5 MB) |
| `GET /v2/ingest`-Listen-`limit` | 1–100 (default 20) |
| Ablauf der signierten `output_url` | 15 Minuten |
| Webhook-Zustellversuche | 4 (initial + 3 Retries) |
| Webhook-Replay-Toleranz | 300 Sekunden |
| Monatliche Ops (über alle Endpunkte geteilt, 1 pro Seite) | 500 / 3.000 / 15.000 / 50.000 je Tarif; siehe [Preise](/de/pricing.md) |

---

## Häufig gestellte Fragen

### Wie crawle ich eine Website für RAG mit einer API?

Sende `POST /v2/ingest` mit `mode: "crawl"` und einer Seed-`url`. Der Aufruf antwortet mit `202` und einer `job_id`; der Worker entdeckt Seiten, rendert jede in Headless Chrome, chunkt das Markdown überschriftenbasiert und assembliert eine JSONL-Datei, die du von der signierten `output_url` herunterlädst.

### Wie ingeste ich Dateien (PDFs, Word-Dokumente) für RAG?

Sende `POST /v2/ingest/files` als `multipart/form-data` mit einer oder mehreren `files`. Jedes Dokument wird zu Markdown konvertiert, überschriftenbasiert gechunkt und in dieselbe einzelne JSONL wie ein Crawl-Job assembliert, sodass eine Pipeline Web und Dateien abdeckt. PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, OpenDocument und Dateien in reinem Text/Markdown werden unterstützt (bis zu 200 pro Request); die vollständige Liste steht auf der [anything-to-markdown](/de/docs/endpoints/convert/documents/anything-to-markdown.md#supported-input-formats)-Seite.

### Lädt die JSONL-Ausgabe direkt in LangChain und LlamaIndex?

Ja. Jede Zeile trägt einen `content`-String auf oberster Ebene mit einem Geschwister-Objekt `metadata`, sodass dieselbe Datei über LangChain `JSONLoader(content_key="content", json_lines=True)`, LlamaIndex `SimpleDirectoryReader` und jeden zeilenorientierten Vektor-DB-Import ohne Umformen lädt.

### Wie teilt der Chunker Seiten in RAG-Chunks auf?

Er teilt an `#`-, `##`- und `###`-Überschriften mit einem weichen `max_words`-Limit (Standard `512`, Bereich 32–4,000) und optionalem `sentence_overlap`. Fenced Codeblöcke und Markdown-Tabellen werden nie geteilt, und jeder Chunk trägt seinen vollständigen `headings_path`.

### Wie werde ich benachrichtigt, wenn ein Ingest-Job fertig ist?

Setze `webhook_url` beim `POST`, und EnConvert sendet einen HMAC-signierten Callback (Header `X-Enconvert-Signature` und `X-Enconvert-Timestamp`) mit bis zu drei Retries nach dem ersten Versuch. Wenn dein Endpunkt ausgefallen war, signiert `POST /v2/ingest/{job_id}/retry-webhook` neu und stellt ihn erneut zu.

### Warum fehlt output_url in meiner Ingest-Antwort?

`output_url` ist nur vorhanden, sobald `status` `completed` ist. Die `POST`-Antwort ist ein `queued`-Job, bei dem das Feld weggelassen wird. Polle `GET /v2/ingest/{job_id}`, was keine Ops verbraucht und die URL bei jedem Aufruf neu signiert; jede signierte URL läuft nach 15 Minuten ab.
