Ingest
POST /v2/ingest 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. It replaces the two-step stack you would otherwise build:
Firecrawl /crawl to discover and scrape, plus your own chunker to slice
the Markdown. EnConvert does the discovery, the headless-Chrome render,
the heading-aware chunking, and the JSONL assembly in one job.
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_live_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.
Worth flagging up front.
/v2/ingestis a V2 endpoint with its own quota counter,ingest_pages. V1 conversion plans include no ingest allowance — you need a V2-inclusive plan to call it. One page is billed for every URL whose render, chunk, and JSONL stage completes; pages that fail or get skipped are not billed. See the pricing page for per-tier allowances, and the V2 overview for how the V2 counters differ from V1 conversions.
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/v2/ingest |
Create an ingest job. Answers 202 with a job_id. |
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_live_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=1quota check (plan has ingest enabled and some monthly headroom), inserts the job row, and answers202. Nothing is persisted if the quota 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. - Render and chunk. Each URL renders through the shared headless Chrome singleton — the same render pipeline behind the perceive endpoint — then the rendered HTML is 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 — if awebhook_urlwas set — the signed completion webhook fires.
The quota counter is re-checked per page inside the worker, not just
at submit time. 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.
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 — 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 — 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, or crawl. |
pages_discovered |
integer |
URLs the job will ingest (the explicit list, or the discovery result). |
pages_processed |
integer |
URLs whose render → chunk → stage completed. |
pages_failed |
integer |
URLs that failed to render or were skipped (e.g. quota 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. |
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 quota 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_live_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_live_your_private_key"
{
"jobs": [
{
"job_id": "ing_3f9a...",
"status": "completed",
"mode": "crawl",
"pages_discovered": 42,
"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 — 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_live_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 — 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), 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_live_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_live_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_live_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_live_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_live_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]);
}
Plan gating
V2 ingest is metered by its own counter, ingest_pages — separate from V1
conversions and from the other V2 counters. One page is billed for every
URL whose render, chunk, and JSONL stage completes; pages that fail or get
skipped are not billed.
The submit-time check is a fast units=1 gate: your plan must have ingest
enabled, with some monthly headroom. The real spend limiter is the
per-page check inside the worker — a crawl job stops cleanly at your
monthly cap and marks the remaining pages skipped rather than
over-running it.
| Gate | Behaviour |
|---|---|
| Plan without ingest enabled | 402 at submit — upgrade to a V2-inclusive plan. |
Monthly ingest_pages quota exhausted |
402 at submit; or, if exhausted mid-job, remaining pages marked skipped. |
| Unlimited (enterprise / admin) | A limit of 0 with the flag enabled means no monthly cap. |
MAX_PAGES_PER_JOB (1,000) is a per-request ceiling that bounds a single
job; it is not your monthly allowance. The monthly ingest_pages quota is
the real spend limiter. Current per-tier numbers live on
the pricing page; see also how perceive operations are
metered for the sibling V2 counter.
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 ingest_pages quota 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 |
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 ingest_pages |
Plan-dependent — see pricing |