File Ingestion#

There are two ways to get bytes into EnConvert: upload the file yourself as multipart/form-data, or pass a url and let the API fetch the resource. Which one you can use depends on the endpoint, not on your plan.


Which endpoints take what#

Endpoint family How the bytes arrive Field name
Data formats, documents, images multipart/form-data upload, one file per request file
Web pages (url-to-pdf, url-to-screenshot, url-to-markdown, website-to-pdf, website-to-screenshot) JSON body, EnConvert fetches the page url
POST /v2/ingest/files multipart/form-data, many files per job files
POST /v2/perceive, POST /v2/ingest JSON body, EnConvert fetches the page url

There is no third way. The file-upload endpoints will not fetch a URL for you, including the catch-all anything-to-pdf and anything-to-markdown routes. To turn a live page into a PDF, call url-to-pdf instead. For which formats each endpoint accepts, see supported formats.


Uploading a local file#

The form field is named file, and every V1 file-upload endpoint takes exactly one. Everything else in the form is optional.

Form field Type Description
file file The file to convert. Its extension must be accepted by the endpoint.
output_filename string Custom base name for the output. The target extension is added for you.
job_id string Client-provided job ID for timeout recovery. Poll GET /v1/convert/status/{job_id} if the connection drops.
pdf_options string JSON string of PDF options, on endpoints that produce a PDF.
direct_download boolean Accepted for request-shape parity with the URL endpoints. It has no effect here: an upload always answers with the JSON envelope below, whatever you send.

curl#

curl -X POST https://api.enconvert.com/v1/convert/anything-to-pdf \
  -H "X-API-Key: sk_your_private_key" \
  -F "[email protected]" \
  -F "direct_download=false"

The response is JSON with a pre-signed download link:

{
    "presigned_url": "https://spaces.example.com/...signed...",
    "object_key": "env/files/{project_id}/anything-to-pdf/quarterly-report_20260714_101530123.pdf",
    "filename": "quarterly-report_20260714_101530123.pdf",
    "file_size": 51240,
    "conversion_time_seconds": 2.1,
    "job_id": null
}

The same values are echoed in the X-Object-Key, X-File-Size, X-Conversion-Time and X-Filename response headers, so you can read them without parsing the body. Fetch the file promptly: the link is short-lived, and signed URLs explains exactly how short.

Python#

import requests

with open("quarterly-report.docx", "rb") as f:
    response = requests.post(
        "https://api.enconvert.com/v1/convert/anything-to-pdf",
        headers={"X-API-Key": "sk_your_private_key"},
        files={"file": ("quarterly-report.docx", f)},
        data={"direct_download": "false"},
    )

response.raise_for_status()
result = response.json()

# Download the PDF from the pre-signed URL.
pdf = requests.get(result["presigned_url"]).content
with open("quarterly-report.pdf", "wb") as out:
    out.write(pdf)

Node.js#

import { readFile, writeFile } from "node:fs/promises";

const form = new FormData();
form.append(
    "file",
    new Blob([await readFile("quarterly-report.docx")]),
    "quarterly-report.docx"
);
form.append("direct_download", "false");

const response = await fetch(
    "https://api.enconvert.com/v1/convert/anything-to-pdf",
    { method: "POST", headers: { "X-API-Key": "sk_your_private_key" }, body: form }
);

const result = await response.json();
const pdf = await fetch(result.presigned_url).then((r) => r.arrayBuffer());
await writeFile("quarterly-report.pdf", Buffer.from(pdf));

Many files in one call#

POST /v2/ingest/files is the only endpoint that accepts more than one file. Repeat the files field, up to 200 files per job:

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"

Every file is converted to Markdown, chunked, and assembled into one JSONL deliverable. The job is always asynchronous and answers 202 Accepted with a job_id. Full details are on the ingest endpoint page.


Letting EnConvert fetch the file#

On the URL endpoints you send a JSON body instead of a form, and the API does the fetching:

curl -X POST https://api.enconvert.com/v1/convert/url-to-markdown \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/report"}'

url accepts a string or an array of strings. An array switches the request to async and is covered in batch processing.

Sources behind a login#

Three optional fields let the fetch carry credentials. All three need a plan with basic auth access (Indie and above).

Parameter Type Default Description
auth object null HTTP Basic Auth credentials: {"username": "...", "password": "..."}.
cookies array null Array of cookie objects injected before navigation. Max 50 per request. Each requires name, value, and either domain or url.
headers object null Custom HTTP headers sent with requests. Max 20 per request. Cannot include blocked headers: host, content-length, transfer-encoding, connection, upgrade, te, trailer.
Credential scoping: Credentials from the auth object, and an Authorization header (for example a Bearer token) passed via headers, are sent only to the target origin, never to third-party subresources the page requests. This prevents credential leakage to ad, analytics, or CDN hosts.

Private and internal addresses#

The url must be a public http:// or https:// address. Before anything is fetched, it is screened and rejected with 400 Bad Request when it:

  • uses a scheme other than http/https;
  • embeds credentials such as https://user:pass@host/ (use the auth field instead);
  • targets localhost, a cloud metadata hostname, or an IP that resolves into a private, loopback, link-local, reserved, or otherwise non-public range;
  • uses a non-standard IP notation (octal, hexadecimal, or packed-integer) that could resolve ambiguously.

This applies to the seed URL, to every URL in a batch, and to the pages discovered by the website-to-* crawl endpoints.

Said plainly: EnConvert runs outside your network. It cannot reach http://10.0.0.5/report.docx, a .internal hostname, or anything that only resolves inside your VPC. Either make the file reachable from the public internet, or read the bytes yourself and upload them.


What happens to your filename#

The name you send does two jobs.

It picks the converter. The extension decides which input path runs, so name the file correctly. A file called report with no extension is rejected by any endpoint that has an extension allowlist.

It seeds the output name. The output filename is built as:

{base}_{YYYYMMDD_HHMMSSmmm}.{ext}

The UTC timestamp is always appended, so two conversions of the same file never collide. base is resolved in this order:

  1. output_filename, if you sent one. If you included the target extension in it, the extension is stripped first so you do not get report.pdf_20260405_123456789.pdf.
  2. The uploaded file's name without its extension. report.docx produces report_20260405_123456789.pdf.
  3. For URL conversions, the domain. https://example.com/page produces example_20260405_123456789.pdf.
  4. Failing all of that, the literal output.

The storage key is sanitised before the result is written: only the basename survives, .. is removed, the characters <>:"|?* are stripped, and spaces become underscores. Upload My Report (final).docx and the PDF lands under My_Report_(final)_20260405_123456789.pdf. That path is returned to you as object_key and has the shape {env}/files/{project_id}/{endpoint}/{filename}.

Filenames sent to POST /v2/ingest/files are additionally capped at 255 characters.


Size ceiling and the 413#

The upload ceiling is per plan and applies to each individual file.

Plan Max upload size Bytes
Founding 5 MB 5242880
Indie 15 MB 15728640
Studio 50 MB 52428800
Production 150 MB 157286400
Enterprise Negotiated Per contract

The size is measured from the uploaded part itself as the body streams in, not from a header you control, so a chunked upload with no Content-Length is checked just the same. Going over returns 413 Payload Too Large before any conversion work happens and before any ops are billed:

{
    "detail": {
        "error": "File too large",
        "file_size": 10485760,
        "max_size": 5242880,
        "tier": "free",
        "key_type": "private"
    }
}

file_size and max_size are both in bytes. tier is the plan slug (free, starter, pro, business, enterprise), not the display name you see on the pricing page, so a Studio project reports "tier": "pro". key_type is private, public or dashboard, falling back to unknown.

5 MB goes fast. On the Founding plan a scanned 40-page PDF or a deck with a few full-bleed photos is usually over the line already. There is no chunked or resumable upload path: the fix is a smaller file or a bigger plan.

Clearing the size gate is not the last hurdle. An exhausted monthly allowance answers 402 Payment Required and too many requests in a window answers 429, both described in rate limits and quotas.


Content type and magic bytes#

Uploads go through two checks, in this order.

1. The extension allowlist. Each endpoint declares which extensions it accepts. A mismatch is 400 Bad Request:

{
    "detail": "Invalid file format '.pdf' for png-to-jpeg. Allowed: .png"
}

2. The magic-byte sniff. The first bytes of the file are compared against the group its extension claims. A high-confidence mismatch is also 400:

{
    "detail": "File content does not match the 'png-to-jpeg' input type."
}

That is what you get when you rename a PDF to .png and upload it: the bytes start with %PDF-, the extension says PNG, and the two disagree. The check exists because the extension is what routes your request. Without it, PDF bytes reach an image decoder and you get an opaque failure deep in the converter instead of a clear 400 at the door, and a deliberately mislabelled file gets handed to a parser that was never meant to see it.

Two things this does not do:

  • The part's Content-Type is never inspected. There is no MIME allowlist anywhere in the upload path, so application/octet-stream is fine. The filename extension is the only thing that routes the request, which is why sending a file with no extension fails.
  • Text formats have no dependable signature and skip the sniff entirely: .json, .csv, .xml, .yaml, .toml, .md, .html, .svg, .txt. A .json file that actually holds CSV is accepted at this stage and fails later, in the parser.

The recognised binary signatures are PNG, JPEG, GIF, WebP, HEIC/HEIF, PDF, and the office group (a ZIP container for .docx/.xlsx/.pptx/ODF/EPUB, or legacy OLE2 for .doc/.xls/.ppt). Sniffing is deliberately fail-open: bytes it does not recognise are allowed through rather than blocked.


Large files: the checklist#

  1. Check the ceiling first. A 413 is cheap for the API and expensive for you, because you uploaded the whole body to earn it.
  2. Expect the upload to be synchronous. async_mode exists only on url-to-pdf, url-to-screenshot and url-to-markdown. File-upload endpoints ignore it and always convert inside the request. See sync and async jobs.
  3. Send a job_id you generated. If a proxy in front of you kills the connection before conversion finishes, the work still completes. Poll GET /v1/convert/status/{job_id} and you get {"status": "processing"}, then either {"status": "success", "presigned_url": ..., "object_key": ...} or {"status": "failed", "error": ...}. Reusing your own ID restarts that job; reusing another project's ID returns 409.
  4. Budget for the timeouts. A request is capped at 300 seconds end to end, after which you get 504 with {"error": "Request timeout"}. LibreOffice-backed office conversions have their own 120-second cap, also surfaced as a 504.
  5. Handle 503 with Retry-After: 10. File conversions run behind an admission gate with a bounded pending queue. When the queue is full the request is refused immediately rather than sitting in line, so retry after the interval in the header.
  6. For many documents, switch endpoints. POST /v2/ingest/files takes up to 200 files, returns 202 straight away with a job_id, and accepts a webhook_url so you never poll. See webhooks.
  7. Download promptly. Output links are signed and expire. If yours lapsed, re-read the status endpoint: every poll mints a fresh link over the same stored object.

Frequently asked questions#

What field name does the EnConvert API expect for a file upload?#

file, sent as multipart/form-data, one file per request, on every V1 conversion endpoint that takes an upload. The exception is POST /v2/ingest/files, which uses files and accepts up to 200 per job.

Can EnConvert download the file from a URL instead of me uploading it?#

Only on the URL endpoints (url-to-pdf, url-to-screenshot, url-to-markdown, website-to-pdf, website-to-screenshot) and the V2 endpoints /v2/perceive and /v2/ingest. The file-upload conversion endpoints have no url parameter. Any URL you pass must be publicly reachable: private, loopback, link-local and cloud metadata addresses are rejected with 400.

Why did my upload return 413 Payload Too Large?#

The file was larger than your plan's per-file ceiling, which is 5 MB on Founding, 15 MB on Indie, 50 MB on Studio and 150 MB on Production. The response body carries a detail object with error, file_size, max_size, tier and key_type so you can show the caller the exact numbers.

Why does my PNG upload fail with "File content does not match"?#

The file's first bytes belong to a different format than its extension claims, for example a PDF renamed to .png. Send the file under its real extension. Text formats such as .json and .csv are never byte-checked, so this error only appears for binary types.

Can I upload a large file asynchronously?#

Not on the V1 file-upload endpoints: they always convert in the request. Send a client-generated job_id and poll GET /v1/convert/status/{job_id} to survive a dropped connection, or use POST /v2/ingest/files, which is asynchronous by design and can call a webhook when the job finishes.