---
seo_title: File Upload API: Multipart, URLs & Size Limits | EnConvert
meta_desc: Every way to get bytes into the EnConvert API: multipart file upload or a URL the API fetches itself, with size ceilings, 413 handling, and filename rules.
keywords: file upload api, multipart form data file upload, 413 payload too large api, max file size per plan, magic byte validation upload, output filename api, convert file from url api
---

# 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](/docs/endpoints/convert/data-formats.md), [documents](/docs/endpoints/convert/documents.md), [images](/docs/endpoints/convert/images.md) | `multipart/form-data` upload, one file per request | `file` |
| [Web pages](/docs/endpoints/convert/web-pages.md) (`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`](/docs/endpoints/ingest.md) | `multipart/form-data`, many files per job | `files` |
| [`POST /v2/perceive`](/docs/endpoints/perceive.md), [`POST /v2/ingest`](/docs/endpoints/ingest.md) | 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](/docs/endpoints/convert/documents/anything-to-pdf.md) and [anything-to-markdown](/docs/endpoints/convert/documents/anything-to-markdown.md) routes. To turn a live page into a PDF, call [url-to-pdf](/docs/endpoints/convert/web-pages/url-to-pdf.md) instead. For which formats each endpoint accepts, see [supported formats](/docs/reference/supported-formats.md).

---

## 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

```bash
curl -X POST https://api.enconvert.com/v1/convert/anything-to-pdf \
  -H "X-API-Key: sk_your_private_key" \
  -F "file=@quarterly-report.docx" \
  -F "direct_download=false"
```

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

```json
{
    "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](/docs/concepts/signed-urls.md) explains exactly how short.

### Python

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

```javascript
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:

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

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](/docs/endpoints/ingest.md).

---

## Letting EnConvert fetch the file

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

```bash
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](/docs/guides/batch-processing.md).

### 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`. |

<div class="alert alert-info">
<strong>Credential scoping:</strong> Credentials from the <code>auth</code> object, and an <code>Authorization</code> header (for example a Bearer token) passed via <code>headers</code>, are sent <strong>only to the target origin</strong>, never to third-party subresources the page requests. This prevents credential leakage to ad, analytics, or CDN hosts.
</div>

### 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:

```json
{
    "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`.

<div class="alert alert-warning">
<strong>5 MB goes fast.</strong> 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.
</div>

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](/docs/reference/rate-limits.md).

---

## 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`:

```json
{
    "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`:

```json
{
    "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](/docs/concepts/sync-and-async.md).
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](/docs/guides/webhooks.md).
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.
