At a glance
- Endpoint:
POST /api/convert,multipart/form-data, a single field:file. - Response: JSON with a
markdownfield and four more (below). - Batch:
POST /api/convert/batchtakes up to 20 files in one request and answers with a result for each of them. - Archive:
POST /api/convert/ziptakes the same request and returns a.zipwith a separate.mdfor every file inside. - Key is optional: without one the free quota applies. A license key lifts the limit.
- Base URL:
https://llmfiles.com.
/openapi.json too: code generators build clients for any language from it. This page covers the same ground in prose.First request in 10 seconds
curl -F "[email protected]" \
https://llmfiles.com/api/convert
The response is a single JSON object. The Markdown sits in the markdown field as one string with real newlines, so jq -r .markdown gives you a finished file:
curl -s -F "[email protected]" \
https://llmfiles.com/api/convert \
| jq -r .markdown > report.md
The file extension is not decoration: it selects the converter. report.pdf is parsed as PDF, report.docx as Word, and a file with no extension goes to plain-text extraction or to the fallback path.
What the response contains
markdown# headings, lists, tables in pipe format, fenced code blocks.filenametitletitle field in a knowledge base.sizefallbacktrue means the format was not recognized and the payload is raw text or a stub carrying the file size. Check the flag before the output goes into a production corpus.archivefound is how many files were inside, converted how many became Markdown, skipped the list with reasons, truncated whether limits were hit.filesname (path inside the archive), out (name of the resulting .md), size, fallback. Useful for mapping results into your own knowledge base.fallback: true means the converter could not handle the input and returned the contents as-is. This does not happen for PDF, DOCX, PPTX, XLSX, EPUB, HTML, CSV, JSON or source code; for exotic formats it does.Response headers let the client track what is left without a second call:
X-Planfree, proX-Quota-Used3X-Quota-Remaining2 or unlimitedunlimited.X-Archive-Files3X-Archive-Skipped1archive.skipped or _manifest.md).X-Archive-Truncated0 or 11 means the archive was not processed in full: a limit on file count, total size or time was hit.Limits
X-License-Key header or the cookie set when you activate a key.The day is the server calendar date (UTC+4) and the counter is keyed to the IP: cf-connecting-ip behind Cloudflare, otherwise the first address in X-Forwarded-For. Cookies play no part in quota counting: otherwise clearing cookies would buy a fresh limit on every request. You can read your own IP from the used field of the /api/license response.
Hit the limit and the server returns 402 with a message about 5 files per day. That is not a client error: retrying is pointless until the next day, so the plan is what has to change.
ZIP archives: a batch of files in one request
Available since version 1.4.0. Inside the archive every file is parsed by its own converter: DOCX stays a document with headings, CSV becomes a table, HTML becomes text, and the output holds a separate .md per file. The folder structure is preserved and extensions are replaced with .md.
curl -F "[email protected]" \
https://llmfiles.com/api/convert/zip \
-o docs-markdown.zip
unzip -o docs-markdown.zip -d out
__MACOSX, .DS_Store, Thumbs.db are skipped.. and absolute paths are rejected, so an archive cannot write files anywhere elseAlongside the .md files the output archive contains _manifest.md: a table of archive file → .md file → size, plus the list of skipped entries with reasons. That way you can process a batch without checking anything by hand.
.zip to /api/convert: the response holds stitched Markdown (up to 4 MB, beyond that a note that not every file is shown) plus the archive and files fields for programmatic use.Batch: many files in one request
Available since version 1.7.0. One request carries up to 20 files under the same field name files. Each file gets its own converter and its own result, so one bad file does not sink the whole batch, and the caller can retry exactly the entries that failed.
curl -F "[email protected]" -F "[email protected]" -F "[email protected]" \
https://llmfiles.com/api/convert/batch \
| jq '{converted, failed, skipped, files: [.files[] | {filename, ok}]}'
The response holds counters, a per-file array in the same order as the request, and the quota state after the batch:
{
"count": 3,
"converted": 2,
"failed": 1,
"skipped": 0,
"files": [
{"filename": "report.pdf", "ok": true, "title": "report", "markdown": "# …", "fallback": false, "size": 812345},
{"filename": "notes.docx", "ok": true, "title": "notes", "markdown": "## …", "fallback": false, "size": 12034},
{"filename": "data.csv", "ok": false, "error": "Empty file", "size": 0}
],
"quota": {"plan": "free", "limit": 5, "used": 2, "remaining": 3}
}
- Quota: one conversion per converted file. That differs from an archive, where the whole
.zipcounts as one. - Running out of quota mid-batch: the files that fit are converted and the rest come back with a
skippedreason instead of failing the request. If the limit left no room for a single file, the response is 402, exactly like a single conversion. - Files per request: 20. Beyond that the answer is 413: split the batch or send a
.zipto/api/convert/zip. - Archives inside a batch: not unpacked. Send the
.zipon its own to/api/convert/zip; the entry comes back with an error saying so. - Headers:
X-Batch-Total,X-Batch-Converted,X-Batch-Failed,X-Batch-Skipped, plus the usualX-Quota-*. - Size limits: the same per-file limit as your plan, and 4x that per request (at most 300 MB).
.md for everything that converted.Python
import requests
API = "https://llmfiles.com/api/convert"
def to_markdown(path: str, license_key: str | None = None) -> str:
headers = {"X-License-Key": license_key} if license_key else {}
with open(path, "rb") as fh:
r = requests.post(API, files={"file": (path, fh)}, headers=headers, timeout=300)
if r.status_code == 402:
raise RuntimeError("daily limit reached: " + r.json()["detail"])
r.raise_for_status()
data = r.json()
if data["fallback"]:
print("unrecognized format:", data["filename"])
return data["markdown"]
print(to_markdown("report.pdf")[:400])
When converting a batch, read X-Quota-Remaining after each file so the loop stops on its own instead of on a 402.
JavaScript
async function toMarkdown(file, licenseKey) {
const body = new FormData();
body.append('file', file);
const res = await fetch('https://llmfiles.com/api/convert', {
method: 'POST',
body,
headers: licenseKey ? { 'X-License-Key': licenseKey } : {},
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.json()).detail}`);
return (await res.json()).markdown;
}
document.querySelector('input[type=file]').onchange = async (e) => {
const md = await toMarkdown(e.target.files[0]);
console.log(md.slice(0, 500));
};
CORS is open to all origins (Access-Control-Allow-Origin: *), so you can call the API straight from a browser on your own domain, without a proxy. Do not hardcode your license key in client-side code: it is visible in the page source.
License key
Two equivalent ways to pass a Pro key. The header is the better fit for scripts and bots:
curl -F "[email protected]" \
-H "X-License-Key: MIW-PRO-XXXXXXXX-XXXXXXXX" \
https://llmfiles.com/api/convert
The cookie is for the browser: POST /api/license/activate with a body of {"key": "MIW-PRO-…"} sets it for a year. A revoked key fails activation (404), and from then on requests fall back to the free quota.
One request confirms that the key took effect: the response reports plan: pro and remaining: null:
curl -s -H "X-License-Key: MIW-PRO-XXXXXXXX-XXXXXXXX" \
https://llmfiles.com/api/license | jq '{plan, remaining}'
Other endpoints
POST /api/convert/zip.zip with a .md per fileapplication/zip: files + _manifest.mdPOST /api/convert/batchconverted, failed, skipped, files[], quotaGET /api/formats{"formats": [...], "count": 24}GET /api/licenseplan, used, remaining, plansPOST /api/license/activate{"ok": true, "plan": "pro"}POST /api/license/deactivate{"ok": true, "plan": "free"}POST /api/orderorder_id, amount, payments_enabledGET /api/order/{id}status, keyPOST /api/downloadfilename + markdown in the form)text/markdown with Content-DispositionGET /healthz{"ok": true, "version": "…"}There is no conversion-ready webhook: requests are synchronous and the response arrives on the same connection. For batches, send up to 20 files to POST /api/convert/batch in one request and read the per-file results; for anything larger, loop over your files and pause when X-Quota-Remaining reaches zero.
Error codes
file field is missing or the file is emptyfile and that the file is not 0 bytes./api/order/{id} not found, or the key will not activatemultipart/form-data, not JSON with a file path. For an archive, check that it holds documents and not just images.detail: {"detail": "File exceeds the 25 MB limit for the free plan."}. Show it to the user as-is: plain prose, no error codes.Privacy and data
- The file is read into process memory and never written out: nothing on disk, nothing in the database, nothing sent to third-party APIs.
- The log records only that an event happened: type, path, hashed IP and size. File name and contents are not logged.
- The quota counter stores a hash of the IP and the date, with no cookies and no history of pages.
- If policy forbids sending documents outside at all, you need the on-prem edition: it runs inside your own network.
More on why this matters and how to verify it yourself is in the guide PDF to Markdown without the cloud.