Crawl Website for RAG API#
POST /v2/ingest crawls a website for RAG: it turns a site (or an
explicit list of URLs) into RAG-ready chunks and emits one JSONL file
that loads straight into LangChain JSONLoader, LlamaIndex
SimpleDirectoryReader, or a vector-DB bulk import. The endpoint is
always asynchronous: POST answers 202 with a job_id, you poll
GET /v2/ingest/{job_id} or register a webhook_url, and a completed job
hands back a pre-signed output_url for the JSONL. EnConvert does the
discovery, the headless-Chrome render, the heading-aware chunking, and the
JSONL assembly in one job.
Uploaded files ingest through the very same pipeline via
POST /v2/ingest/files. PDF, DOCX, PPTX, XLSX, CSV,
HTML, EPUB, and more are converted to Markdown, chunked, and assembled into
the same JSONL. One integration covers both web and file RAG ingestion.
Here is the smallest useful call. Crawl a site and chunk every page it discovers:
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"
}'
The response is the job record, returned with 202 Accepted. Note the
status is queued, and output_url is absent until the job completes:
{
"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 is always asynchronous. Each page renders in a real browser,
which runs 10-30 seconds per URL, far past the 300-second request window
for any non-trivial job. So POST answers 202 with a job_id, and a
droplet-local worker drains the job out of band. You poll
GET /v2/ingest/{job_id} for progress, or register a webhook_url to be
told when it finishes.
Endpoints#
| Method | Path | Purpose |
|---|---|---|
POST |
/v2/ingest |
Create a web ingest job (URL list, sitemap, or crawl). Answers 202 with a job_id. |
POST |
/v2/ingest/files |
Create a file ingest job from uploaded documents (multipart). Same job + JSONL pipeline. |
GET |
/v2/ingest |
Newest-first list of this project's jobs, with skip/limit paging. |
GET |
/v2/ingest/{job_id} |
Lifecycle status of one job, with a freshly signed output_url once completed. |
DELETE |
/v2/ingest/{job_id} |
Cancel a job. The worker sees the canceled status and stops between pages. |
POST |
/v2/ingest/{job_id}/retry-webhook |
Re-sign and re-POST the completion webhook for a completed job. |
GET |
/v2/ingest/webhook-secret |
Reveal the project's webhook signing secret (dashboard channel). |
POST |
/v2/ingest/webhook-secret/rotate |
Rotate the signing secret. Old signatures stop verifying immediately. |
Content-Type: application/json on every POST.
Authentication#
Authenticate with a private key in the X-API-Key header for
server-to-server calls. This is the path the examples below use.
X-API-Key: sk_your_private_key
Public keys with a JWT bearer token also work, using the same flow as
every other endpoint: generate a token with your pk_ key, then send it
as Authorization: Bearer <token>. The full flow, including domain
locking and token refresh, is in the authentication guide.
Each API key carries an allowed-endpoints allowlist. If /v2/ingest is
not on the key's list, the request is rejected with 403. A key scoped
to /v2/ingest still reaches the jobs it created: GET and DELETE
/v2/ingest/{job_id} and POST /v2/ingest/{job_id}/retry-webhook are
always allowed for a job_id (the ing_… shape is matched explicitly).
The static list endpoint and the two webhook-secret management routes
do not inherit that bypass; they require a broader or dashboard-scoped
token.
How ingest works#
One job runs through five phases, all durable and restart-safe. If the worker process restarts mid-job, the job is re-enqueued at boot and resumes from the page it stopped on. Already-completed pages keep their staged output and are never re-rendered or re-billed.
- Queue.
POSTvalidates the request, runs a fastunits=1ops check (plan has ingest enabled and headroom left in the monthly ops allowance), inserts the job row, and answers202. Nothing is persisted if the ops gate fails: a402leaves zero rows behind. - Discover. For
sitemapandcrawlmode the worker runs the same discovery pass as the discover endpoint, capped atmax_pages, and SSRF-screens the seed URL. Forurlsmode the explicit list is de-duplicated in order; no discovery runs. The pre-cap discovery size is reported aspages_found; when the site has more URLs thanmax_pagesallowed,discovery_truncatedistrueand awarningsentry states both numbers, sopages_discovered(the enqueued count) is never mistaken for the site's size. - Render and chunk. Each URL renders through the shared headless Chrome singleton, the same render pipeline behind the perceive endpoint. The rendered HTML is then converted to fit-Markdown and sliced by the heading-aware chunker. Renders run sequentially, one page at a time.
- Stage. Each page's chunks are written to a per-page JSONL object in
storage, keyed deterministically by
(project, job, url). This is what makes a restart cheap: a resumed job re-uses staged pages instead of re-rendering them. - Assemble. Once every page is done, the per-page objects are
concatenated into the final
v2-ingest/{job_id}.jsonl, the staging objects are deleted, the job flips tocompleted, and the signed completion webhook fires if awebhook_urlwas set.
The ops allowance is re-checked per page inside the worker, not just
at submit time; each completed page bills one op. A crawl job whose
page count is unknown up front stops cleanly at your monthly cap: the
pages already rendered are billed and kept, and the remaining pages are
marked skipped rather than over-spending.
Ingest renders are credential-free by design. Unlike /v2/perceive,
it does not accept auth, cookies, or custom headers. Nothing secret
is persisted for the durable resume, so the job state on disk never
carries credentials.
Ingesting files#
POST /v2/ingest crawls the web; POST /v2/ingest/files ingests uploaded
files through the very same pipeline. Both create the same job, run the
same heading-aware chunker, and produce the same single JSONL deliverable,
so one integration covers web and file RAG ingestion.
Send the documents as multipart/form-data in the files field. Each file
is converted to Markdown by the anything-to-markdown
converter, then chunked and assembled exactly like a crawled page. Every
supported input format
is accepted: PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, OpenDocument, and plain
text/Markdown.
curl -X POST https://api.enconvert.com/v2/ingest/files \
-H "X-API-Key: sk_your_private_key" \
-F "[email protected]" \
-F "[email protected]" \
-F "[email protected]" \
-F "max_words=700" \
-F "webhook_url=https://your-app.example.com/hooks/ingest"
The response is the same IngestJobResponse as the crawl endpoint, with
mode set to files:
{
"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"
}
Each uploaded file counts as one "page": it bills one op,
climbs pages_processed as it completes, and is labelled by its filename
in the JSONL metadata.source_url. You poll GET /v2/ingest/{job_id}, cancel
with DELETE, and receive the signed completion webhook exactly as for a crawl
job. Files are stored only until the JSONL is assembled, then deleted.
File request parameters#
Sent as multipart form fields (not a JSON body):
| Field | Type | Default | Description |
|---|---|---|---|
files |
file[] | -- | One or more documents to ingest. 1–200 files per request; each is size-checked against your plan's upload limit. |
max_words |
integer |
512 |
Soft cap on words per chunk. 32–4,000. Code blocks and pipe tables stay atomic. |
sentence_overlap |
integer |
1 |
Sentences repeated between consecutive prose chunks of the same section. 0–10. |
webhook_url |
string |
null |
HMAC-signed completion callback, with the same signing and retry policy as completion webhooks below. |
An unsupported file type, an empty file, or an image (OCR is not performed) is
rejected at submit with a 400; a file over your plan's per-file size limit is
a 413.
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 parameters#
Source and mode#
| Parameter | Type | Default | Description |
|---|---|---|---|
mode |
string |
"urls" |
urls, sitemap, or crawl. Selects how the URL set is built. |
url |
string |
null |
Seed URL for sitemap/crawl mode. Must start with http:// or https://. Max 2,048 characters. Required for those modes; rejected in urls mode. |
urls |
string[] |
null |
Explicit URLs to ingest in urls mode. Non-empty, max 1,000 entries, each http(s) and ≤ 2,048 chars. Required for urls mode; rejected in sitemap/crawl mode. |
url and urls are mutually exclusive: send exactly one source. urls mode
requires urls; sitemap and crawl require a seed url. Sending the
wrong one for the mode is a 422.
Discovery (sitemap / crawl modes)#
These are forwarded to the discovery pass and ignored in urls mode.
| Parameter | Type | Default | Description |
|---|---|---|---|
max_pages |
integer |
50 |
Cap on URLs discovered and ingested. 1–1,000. |
max_depth |
integer |
2 |
Crawl link depth from the seed. 1–5. |
same_domain_only |
boolean |
true |
Restrict discovery to the seed's domain. |
include_patterns |
string[] |
[] |
Regex patterns a URL must match to be kept. Max 50. Each is compiled at submit; a bad pattern is a 422. |
exclude_patterns |
string[] |
[] |
Regex patterns that drop a matching URL. Max 50. |
respect_robots |
boolean |
false |
When true, a URL disallowed by the site's robots.txt is skipped. |
Rendering#
| Parameter | Type | Default | Description |
|---|---|---|---|
wait_for |
string |
null |
Wait after navigation for a CSS selector or JS expression before capturing. Max 1,024 characters. |
wait_timeout_ms |
integer |
30000 |
How long wait_for may wait, in milliseconds. 0–60,000. |
Note. Ingest does not accept
auth,cookies, orheaders. If a page needs credentials to render, ingest is the wrong tool. Use the perceive endpoint, which carries the full authenticated-request surface, for that single page.
Chunking (chunk object)#
| Parameter | Type | Default | Constraints | Description |
|---|---|---|---|---|
max_words |
integer |
512 |
32–4,000 | Soft cap on words per chunk. Heading-aware. Code blocks and pipe tables stay atomic and may exceed this. |
sentence_overlap |
integer |
1 |
0–10 | Sentences repeated between consecutive prose chunks of the same section. 0 disables overlap. Overlap never crosses a heading boundary. |
The chunker splits on #, ##, and ### headings, so each chunk belongs
to exactly one section and carries its full heading path. Deeper
headings (####–######) stay inline as content. Fenced code blocks and
Markdown tables are never split, even when a single block runs over
max_words; list items split between items, never mid-item.
Webhook#
| Parameter | Type | Default | Description |
|---|---|---|---|
webhook_url |
string |
null |
Endpoint to receive the HMAC-signed completion callback. Max 2,048 characters. Scheme-checked at submit; SSRF-screened at delivery time, not submit time. |
Response#
POST, GET /v2/ingest/{job_id}, and DELETE all return the same
IngestJobResponse object.
| Field | Type | Description |
|---|---|---|
job_id |
string |
Opaque ID (ing_…). Use it with the GET/DELETE endpoints and quote it to support. |
status |
string |
queued, discovering, processing, completed, failed, or canceled. |
mode |
string |
The mode you submitted: urls, sitemap, crawl, or files. |
pages_discovered |
integer |
Items the job actually enqueued: URLs (the explicit list, or the discovery result capped at max_pages), or uploaded files. pages_processed + pages_failed sum to this once the job is terminal. |
pages_found |
integer |
Unique eligible URLs discovery yielded before the max_pages cap. For sitemap jobs this is the site's true unique count; for crawl jobs it is a lower bound (the crawl stops fetching at the cap). Absent for urls and files jobs. |
discovery_truncated |
boolean |
true when discovery found more unique URLs than max_pages let the job enqueue. A warnings entry spells out the numbers; raise max_pages to ingest more of the site. |
pages_processed |
integer |
URLs whose render → chunk → stage completed. |
pages_failed |
integer |
URLs that failed to render or were skipped (e.g. ops allowance exhausted). |
total_chunks |
integer |
Total chunks written across all completed pages. Matches the JSONL line count. |
output_url |
string |
Pre-signed download URL for the final JSONL. Present only once status is completed; expires after 15 minutes. |
error_message |
string |
Set when status is failed (e.g. discovery rejected, all pages failed). |
webhook_url |
string |
The completion-webhook target registered for this job, if any. |
webhook_delivered |
boolean |
true once the signed completion webhook got a 2xx. |
created_at |
string |
When the job was created (UTC). |
completed_at |
string |
When the job reached a terminal status (UTC). |
warnings |
string[] |
Non-fatal notes, e.g. discovery truncation: "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.
POSTand the per-jobGET/DELETEuseresponse_model_exclude_none, so fields that are stillnull(likeoutput_urlbefore completion) are omitted from the JSON rather than sent asnull.
The JSONL record shape#
The final file is newline-delimited JSON. Each line is one chunk:
{"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 |
Deterministic per (source_url, chunk_index): <md5(url)[:12]>-<index:04d>. A re-run produces identical ids. |
content |
string |
The retrievable chunk text. Maps to Document.page_content in LangChain. |
metadata.source_url |
string |
The page the chunk came from. |
metadata.title |
string |
Page <title>, falling back to the first <h1>, capped at 512 chars. |
metadata.headings_path |
string[] |
The h1 → h2 → h3 path the chunk sits under. |
metadata.section |
string |
The innermost heading text (the last entry of headings_path). |
metadata.word_count |
integer |
Whitespace-delimited word count of content. |
metadata.chunk_index |
integer |
The chunk's index within its page. |
The file is UTF-8, written with ensure_ascii=false, so unicode stays
readable. Because content is a top-level string and metadata is a
sibling object, the same file loads through LangChain
JSONLoader(content_key="content", json_lines=True), LlamaIndex
SimpleDirectoryReader, and any line-oriented vector-DB import without
reshaping.
Job lifecycle and polling#
A job moves through these states:
queued → discovering → processing → completed | failed | canceled
| Status | Meaning |
|---|---|
queued |
Accepted and waiting for the worker. |
discovering |
Running the sitemap/crawl discovery pass (sitemap/crawl only). |
processing |
Rendering and chunking pages. pages_processed and total_chunks climb live. |
completed |
The final JSONL is assembled; output_url is signed and ready. |
failed |
Discovery was rejected, or every page failed or was skipped. error_message explains. |
canceled |
A DELETE reached the job before it finished. |
Poll the status with the per-job GET. This is read-only: it consumes
no ops and re-signs the output_url from the stored object key on every
call:
curl https://api.enconvert.com/v2/ingest/ing_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7 \
-H "X-API-Key: sk_your_private_key"
An unknown job_id, or one that belongs to a different project, returns
404. Existence is never leaked across projects.
Listing jobs#
GET /v2/ingest returns this project's jobs newest-first, with skip and
limit query params. limit defaults to 20 and is capped at 100. The
response carries a has_more flag instead of a total count:
curl "https://api.enconvert.com/v2/ingest?skip=0&limit=20" \
-H "X-API-Key: sk_your_private_key"
{
"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
}
The list rows reduce webhook_url to a webhook_configured boolean, so
the list never echoes the raw endpoint back into the table.
Canceling a job#
DELETE /v2/ingest/{job_id} sets the job's status to canceled. The
worker reads that status between pages and stops without assembling
output. Cancellation is idempotent and race-proof: if assembly already
committed, the DELETE matches nothing and the job is returned unchanged
as completed. A finished job is never clobbered back to canceled.
curl -X DELETE \
https://api.enconvert.com/v2/ingest/ing_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7 \
-H "X-API-Key: sk_your_private_key"
Completion webhooks#
Set webhook_url on the POST and EnConvert sends one HMAC-signed POST
when the job completes. The payload is compact, key-sorted JSON:
{"job_id":"ing_3f9a...","output_url":"https://spaces.example.com/...signed...","pages_processed":41,"status":"completed","total_chunks":1187}
Delivery retries up to three times after the first attempt, with back-off delays of 1, 4, and 16 seconds, which is four POSTs worst case. Each attempt is re-signed with a fresh timestamp, so a slow retry chain never drifts past the consumer's freshness window. A 2xx response is success. A dead endpoint is recorded as a non-delivery and raises a dashboard alert, but it never sinks an otherwise-completed job.
The webhook_url is SSRF-screened at delivery time, not at submit. A
URL that resolves to a private, loopback, or metadata address is stored
inertly and only rejected when EnConvert tries to POST to it.
Verifying the signature#
Each delivery carries two headers:
| Header | Value |
|---|---|
X-Enconvert-Signature |
sha256=<hex>, the HMAC-SHA256 of <timestamp>.<raw body>. |
X-Enconvert-Timestamp |
The unix-seconds timestamp bound into the signature. |
The signing input is the timestamp, a literal ., then the raw request
body. Binding the timestamp into the MAC means a consumer that rejects
stale timestamps gets replay protection for free. The default freshness
window is 300 seconds. Verify in your handler:
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)
Managing the signing secret#
GET /v2/ingest/webhook-secret reveals the project's secret (creating it
on first call) along with the header names and tolerance your consumer
needs. It is sensitive and exposed only over the authenticated
dashboard channel:
{
"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 issues a new secret and sets
rotated to true. Every signature computed with the previous secret
stops verifying the moment the rotation commits. Rotate after a suspected
leak, then update your consumer.
Re-delivering a webhook#
If your endpoint was down when the job finished,
POST /v2/ingest/{job_id}/retry-webhook re-signs and re-POSTs with the
same retry policy:
curl -X POST \
https://api.enconvert.com/v2/ingest/ing_3f9a.../retry-webhook \
-H "X-API-Key: sk_your_private_key"
{
"job_id": "ing_3f9a...",
"delivered": true,
"attempts": 1,
"status_code": 200,
"detail": "Delivered (HTTP 200)."
}
It returns 404 for an unknown or foreign job_id, 400 when no
webhook_url is configured (or the stored URL now resolves to a
private/internal address), and 409 when the job has not reached
completed.
Code examples#
curl: explicit URL list#
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 with chunking and a webhook#
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: submit, poll, download#
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: submit and poll#
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]);
}
Error responses#
| Status | Condition |
|---|---|
202 Accepted |
The job was created and enqueued. This is the normal POST outcome. |
401 Unauthorized |
Missing or invalid API key / JWT token. |
402 Payment Required |
Ingest is not on your current plan, or your monthly ops allowance is exhausted. |
403 Forbidden |
/v2/ingest is not in the API key's allowed endpoints. |
404 Not Found |
Unknown job_id, or one owned by another project. |
409 Conflict |
retry-webhook called on a job that has not reached completed. |
400 Bad Request |
retry-webhook called with no webhook_url configured, or its stored URL now resolves to a private/internal address. |
422 Unprocessable Entity |
Source does not match mode (urls without urls, or a seed url in urls mode); a parameter is out of range; or an include_patterns/exclude_patterns regex does not compile. |
500 Internal Server Error |
The job could not be created. The message includes the job_id to quote to support. |
A page-level render failure does not fail the request or the job. It
increments pages_failed, lands the page's error in its own row, and the
job continues. A job only fails when discovery is rejected or every page
fails or is skipped. The full status-code reference is in
the error-codes guide.
Limits#
| Limit | Value |
|---|---|
URLs per urls-mode request |
1,000 |
url / each urls entry length |
2,048 characters |
max_pages (discovery cap) |
1–1,000 |
max_depth |
1–5 |
include_patterns / exclude_patterns |
50 each |
wait_for length |
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) |
webhook_url length |
2,048 characters |
Per-job page ceiling (MAX_PAGES_PER_JOB) |
1,000 |
Files per /v2/ingest/files request |
1–200 |
| Per-file upload size | Plan-dependent (Founding: 5 MB) |
GET /v2/ingest list limit |
1–100 (default 20) |
Signed output_url expiry |
15 minutes |
| Webhook delivery attempts | 4 (initial + 3 retries) |
| Webhook replay tolerance | 300 seconds |
| Monthly ops (shared across every endpoint, 1 per page) | 500 / 3,000 / 15,000 / 50,000 by tier; see pricing |
Frequently asked questions#
How do I crawl a website for RAG with an API?#
Send POST /v2/ingest with mode: "crawl" and a seed url. The call answers 202 with a job_id; the worker discovers pages, renders each in headless Chrome, chunks the Markdown heading-aware, and assembles one JSONL file you download from the signed output_url.
How do I ingest files (PDFs, Word docs) for RAG?#
Send POST /v2/ingest/files as multipart/form-data with one or more files. Each document is converted to Markdown, chunked heading-aware, and assembled into the same single JSONL as a crawl job, so one pipeline covers web and files. PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, OpenDocument, and plain-text/Markdown files are supported (up to 200 per request); the full list is on the anything-to-markdown page.
Does the JSONL output load into LangChain and LlamaIndex directly?#
Yes. Each line carries a top-level content string with a sibling metadata object, so the same file loads through LangChain JSONLoader(content_key="content", json_lines=True), LlamaIndex SimpleDirectoryReader, and any line-oriented vector-DB import without reshaping.
How does the chunker split pages into RAG chunks?#
It splits on #, ##, and ### headings with a soft max_words cap (default 512, range 32–4,000) and optional sentence_overlap. Fenced code blocks and Markdown tables are never split, and every chunk carries its full headings_path.
How do I get notified when an ingest job finishes?#
Set webhook_url on the POST and EnConvert sends one HMAC-signed callback (headers X-Enconvert-Signature and X-Enconvert-Timestamp) with up to three retries after the first attempt. If your endpoint was down, POST /v2/ingest/{job_id}/retry-webhook re-signs and re-delivers it.
Why is output_url missing from my ingest response?#
output_url is present only once status is completed. The POST answer is a queued job with the field omitted. Poll GET /v2/ingest/{job_id}, which consumes no ops and re-signs the URL on every call; each signed URL expires after 15 minutes.