FileMorph provides a REST API for programmatic file conversion and compression.
All responses are either a file download (application/octet-stream) or JSON.
Base URL: http://localhost:8000/api/v1
FileMorph supports two parallel authentication schemes:
| Scheme | Header | Issued by | Use case |
|---|---|---|---|
| API key (Community) | X-API-Key: <key> |
scripts/generate_api_key.py |
Self-host scripts, automation, CLI tooling |
| JWT Bearer (Cloud overlay) | Authorization: Bearer <token> |
POST /api/v1/auth/login |
Browser sessions, multi-user deployments |
The file endpoints (/convert, /compress, their /batch variants, /pdf/* and /ai/redact/*) take either header but need neither: a request without credentials runs on the anonymous tier (so /ai/redact/apply, which needs a paid plan, answers it with 403). Credentials you do send are checked. An X-API-Key must be valid — a key from scripts/generate_api_key.py or an active key created in the dashboard (POST /api/v1/keys) — otherwise the answer is 401, and 429 once your IP has sent 30 rejected keys in a minute (see Failed API-key attempts). An invalid or expired Bearer token is not rejected on these endpoints: it is ignored, and the request runs without your account — refresh the token before it expires (see the API usage guide). /health, /ready, /formats and /contact are public; the account endpoints (/api/v1/auth/*, /api/v1/keys, /api/v1/billing/*) require a JWT unless the tables below say otherwise.
Generate a key:
python scripts/generate_api_key.py
# or via Docker:
docker compose exec filemorph python scripts/generate_api_key.pyKeys are stored as SHA-256 hashes in data/api_keys.json. The plaintext key is shown exactly once at generation time. There is no key-rotation endpoint in the Community Edition — generate a new key and remove the old hash from the JSON file.
Requests with these keys run on the anonymous tier unless API_KEYS_FILE_TIER names another one — see Limits on a Community Edition instance.
When DATABASE_URL is configured, the Cloud overlay enables registration / login / refresh:
# Register (returns access + refresh tokens)
curl -X POST http://localhost:8000/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"correct-horse-battery-staple"}'
# Login on a returning device
curl -X POST http://localhost:8000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"correct-horse-battery-staple"}'
# Use the access token
curl http://localhost:8000/api/v1/auth/me \
-H "Authorization: Bearer <access-token>"
# Refresh expired access tokens (15 min TTL on access, 30 d on refresh)
curl -X POST http://localhost:8000/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token":"<your-refresh-token>"}'Logged-in users can also generate API keys bound to their account at POST /api/v1/keys; those keys count against the user's tier quota rather than the anonymous tier.
All tokens (access, refresh, password-reset, email-verify) carry the RFC 7519 iss and aud claims — iss=JWT_ISSUER (default filemorph), aud=JWT_AUDIENCE (default filemorph-api) — and every decode path validates them. A token minted by a different FileMorph deployment, or by anything that shares a leaked JWT_SECRET but uses a different audience, is rejected before any business logic runs. Multi-instance operators behind one identity provider should set a distinct JWT_AUDIENCE per instance. Changing either value invalidates every in-flight token on the next request.
The endpoints in this section only respond when the Cloud overlay is configured (DATABASE_URL set, and where applicable STRIPE_SECRET_KEY). Without those, they return 503 Service Unavailable. With DATABASE_URL set, the app does not start at all unless JWT_SECRET is at least 32 characters and not a published placeholder (see docs/self-hosting.md). All require Authorization: Bearer <jwt> unless noted.
Auth (/api/v1/auth/*)
| Method + Path | Auth | Purpose |
|---|---|---|
POST /api/v1/auth/register |
none | Create account; returns access + refresh tokens. Sends a verification email (fire-and-forget) in the request locale, and stores that locale as preferred_lang. |
POST /api/v1/auth/login |
none | Exchange email + password for access (15 min) + refresh (30 d) tokens. |
POST /api/v1/auth/refresh |
none (refresh-token in body) | Issue a new access token. |
GET /api/v1/auth/me |
Bearer | Return the currently authenticated user (id, email, tier, role, created_at, subscription_status, preferred_lang). |
PUT /api/v1/auth/account/language |
Bearer | Set the language for this user's transactional email. Body: {"preferred_lang": "de"|"en"} — an unsupported value is a 422. Returns the updated user object. This is email locale only; the web-UI locale stays URL-prefix driven (no cookie). |
POST /api/v1/auth/forgot-password |
none | Issue a single-use password-reset link via email (30 min TTL). |
POST /api/v1/auth/reset-password |
reset-token in body | Set a new password and invalidate older sessions via password-hash rotation. |
POST /api/v1/auth/verify-email |
verify-token | Mark the user's email as verified. |
POST /api/v1/auth/resend-verification |
Bearer | Re-send the verification mail (auth-required to avoid spam). |
DELETE /api/v1/auth/account |
Bearer | Self-service account deletion (GDPR Art. 17). Requires re-confirmation: current password, registered email, and the literal string DELETE. Success is 204. Free / never-paid accounts are hard-deleted; an account linked to Stripe is retained in a restricted state — only email, the Stripe customer id, the last tier, and created_at are kept for the 10-year HGB §257 / AO §147 invoice record (permitted by GDPR Art. 17(3)(b)), every other personal field is erased, and the row is hard-deleted at the end of the retention period. Any active Stripe subscription is cancelled first; a Stripe API error returns 500 and leaves the account unchanged. See docs/gdpr-account-deletion-design.md. |
API keys (/api/v1/keys)
| Method + Path | Auth | Purpose |
|---|---|---|
POST /api/v1/keys |
Bearer | Create a new API key bound to the authenticated user (201). Plaintext key is shown exactly once in the response. JSON body {"label": "…"} — label is optional (at most 100 characters, default My API Key), the body is not: send {} for the default. An account holds at most 25 active keys; beyond that the response is 409 Conflict — revoke a key you no longer use first. |
GET /api/v1/keys |
Bearer | List the user's active keys (id, label, created_at, last_used_at, is_active). The key itself is never shown again. |
DELETE /api/v1/keys/{id} |
Bearer | Revoke a key. |
Billing (/api/v1/billing/*)
| Method + Path | Auth | Purpose |
|---|---|---|
POST /api/v1/billing/checkout/{tier} |
Bearer | Start a Stripe Checkout for pro / business. Body MUST include withdrawal_waiver_acknowledged: true (BGB §356 (5) consent — see terms.html § 9). Returns the Stripe Checkout URL; a billing.checkout.withdrawal_waiver_recorded audit event is written before the redirect. |
POST /api/v1/billing/portal |
Bearer | Return a Stripe Customer Portal URL so the user can manage card / cancel / re-subscribe. |
POST /api/v1/billing/webhook |
Stripe signature | Stripe → FileMorph webhook receiver. Handles customer.subscription.{created,updated,deleted} (tier sync from price + status) and invoice.payment_failed (dunning: marks subscription_status=past_due, sends a "payment failed — update your card" email once per retry cycle, keeps the paid tier during Stripe's grace window, and downgrades to Free only on a terminal status). Not exposed in OpenAPI. |
For schema details (request bodies, response shapes), open the auto-generated Swagger UI at /docs on the live deployment.
Respond only when AI_OPERATIONS_ENABLED is set; otherwise 503 ai_unavailable.
Two-phase. See pii-redaction.md for capability + limits.
| Method | Endpoint | Body | Notes |
|---|---|---|---|
POST |
/api/v1/ai/redact/detect |
file, entity_types (optional CSV) |
Free, open to anonymous/free. Returns JSON {findings:[{entity_type,value,location,confidence}], count, credits_estimate, credits_remaining}. |
POST |
/api/v1/ai/redact/apply |
file, entity_types (optional), mode (replace|mask|remove, default replace) |
Paid-tier only, credit-metered. Returns the redacted file as a download — DOCX and XLSX keep their format, every text input comes back as UTF-8 text named <name>.redacted.txt — with headers X-FileMorph-AI-Entities-Redacted, X-FileMorph-AI-Credits-Cost and, when your tier has a monthly credit limit, X-FileMorph-AI-Credits-Remaining. |
entity_types is a comma-separated subset of EMAIL, IBAN, PHONE, IPV4,
CREDIT_CARD (case-insensitive); empty means all of them.
Each successful apply costs AI_CREDIT_COST_REDACT credits (default 1),
counted against your tier's monthly allotment (calendar month, UTC;
ai_credits_per_month in app/core/quotas.py, unlimited on Enterprise). The
charge is made only after the output passed verification, so a failed run
costs nothing. detect is free: credits_estimate is what apply would
charge, and credits_remaining is null when there is no limit to count
against (anonymous callers, unlimited tiers).
Supported inputs: UTF-8 text (.txt, .md, .csv, .tsv, .json, .xml,
.html, .yaml, .ini, .log and a few aliases, or no extension), DOCX,
XLSX. Responses are credit-denominated only —
no model id, token count, or euro cost. Error codes (X-FileMorph-Error-Code):
ai_unavailable (503), ai_plan_required (403), ai_credits_exhausted (402),
unsupported_format (415, incl. PDF by design), input_too_large (413),
unknown_entity_type (400), document_unreadable (400),
redaction_verification_failed (500, fail-closed — no file returned),
output_cap_exceeded (413).
Convert a file from one format to another.
Authentication: Optional — X-API-Key or Authorization: Bearer; without credentials the request runs on the anonymous tier (see Authentication)
Request: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
file |
file | Yes | The file to convert |
target_format |
string | Yes | Target format extension, e.g. jpg, pdf, mp3 |
quality |
integer | No | Quality 1–100 (default: 85). Applies to lossy targets (JPEG, WebP, AVIF, video, lossy audio) |
Response: 200 OK — the converted file as a download, named <name>.<target_format> — <name>_pdfa.pdf for target_format=pdfa (see Download names). A DOCX → PDF conversion whose layout had to be simplified also carries X-FileMorph-Warnings (see Conversion warnings).
Example — HEIC to JPG
curl -X POST http://localhost:8000/api/v1/convert \
-H "X-API-Key: YOUR_KEY" \
-F "file=@photo.heic" \
-F "target_format=jpg" \
-F "quality=90" \
--output photo.jpgExample — DOCX to PDF
curl -X POST http://localhost:8000/api/v1/convert \
-H "X-API-Key: YOUR_KEY" \
-F "file=@document.docx" \
-F "target_format=pdf" \
--output document.pdfExample — Python (requests)
import requests
key = "YOUR_KEY"
with open("photo.heic", "rb") as f:
response = requests.post(
"http://localhost:8000/api/v1/convert",
headers={"X-API-Key": key},
files={"file": ("photo.heic", f, "image/heic")},
data={"target_format": "jpg", "quality": 85},
)
with open("photo.jpg", "wb") as out:
out.write(response.content)Example — JavaScript (fetch)
const formData = new FormData();
formData.append("file", fileInput.files[0]);
formData.append("target_format", "jpg");
formData.append("quality", "85");
const response = await fetch("http://localhost:8000/api/v1/convert", {
method: "POST",
headers: { "X-API-Key": "YOUR_KEY" },
body: formData,
});
const blob = await response.blob();
const url = URL.createObjectURL(blob);
// use url for download linkReduce a file's size by re-encoding at a lower quality, keeping the same format.
Authentication: Optional — X-API-Key or Authorization: Bearer; without credentials the request runs on the anonymous tier (see Authentication)
Request: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
file |
file | Yes | The file to compress |
quality |
integer | No | Quality 1 (smallest) – 100 (best). Defaults to 85. Mutually exclusive with target_size_kb |
target_size_kb |
integer | No | Target output size in KB, at least 5. Activates binary-search-on-quality (JPEG/WebP/AVIF only). Mutually exclusive with quality |
Supported formats: JPG, JPEG, PNG, WebP, AVIF, TIFF, TIF · MP4, MOV, AVI, MKV, WebM. FLV and WMV can be converted but not compressed (422). A video keeps its container; see formats.md for the codecs and how quality maps onto them.
target_size_kb is JPEG/WebP/AVIF only — PNG/TIFF are lossless and quality does not control size meaningfully. Sending target_size_kb with a PNG returns 415. AVIF/AV1 encode is more CPU-intensive than JPEG/WebP, and target-size runs several encode passes.
Response: 200 OK — the compressed file as a download (same format, _compressed suffix in filename).
When target_size_kb is set, the response also carries:
| Header | Description |
|---|---|
X-FileMorph-Achieved-Bytes |
Actual output size in bytes |
X-FileMorph-Final-Quality |
Quality value the search settled on (1–100) |
Tolerance is ±3 % of the requested target: the result is at most 3 % over it, but can be smaller (an image that already fits at quality 95 is returned at 95). If even quality 1 exceeds the target, the smallest possible output is returned anyway and the headers reveal the actual size.
Example — Compress a JPG to 70% quality
curl -X POST http://localhost:8000/api/v1/compress \
-H "X-API-Key: YOUR_KEY" \
-F "file=@large_photo.jpg" \
-F "quality=70" \
--output smaller_photo.jpgExample — Compress a JPG to a 500 KB target
curl -X POST http://localhost:8000/api/v1/compress \
-H "X-API-Key: YOUR_KEY" \
-F "file=@large_photo.jpg" \
-F "target_size_kb=500" \
-D headers.txt \
--output capped_photo.jpg
# headers.txt now contains X-FileMorph-Achieved-Bytes / X-FileMorph-Final-QualityExample — Compress a video
curl -X POST http://localhost:8000/api/v1/compress \
-H "X-API-Key: YOUR_KEY" \
-F "file=@recording.mp4" \
-F "quality=60" \
--output recording_compressed.mp4Quality guide for images
| Quality | Typical size reduction | Visual difference |
|---|---|---|
| 90 | ~20% | Nearly invisible |
| 80 | ~40% | Very subtle |
| 70 | ~55% | Slightly noticeable on close inspection |
| 60 | ~65% | Noticeable, acceptable for web thumbnails |
| 50 | ~70% | Clearly visible, good for previews |
Write a new PDF containing only a selected page range — a structural morph, not a format conversion. Text, fonts and vector content are copied intact.
Authentication: Optional — X-API-Key or Authorization: Bearer; without credentials the request runs on the anonymous tier (see Authentication)
Request: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
file |
file | Yes | The source PDF (must be a .pdf) |
pages |
string | Yes | 1-based page selection — comma-separated singletons and a-b ranges, e.g. 1-3,5. Sorted + de-duplicated. |
Response: 200 OK (application/pdf) — the extracted pages as a download
(_pages.pdf suffix). An empty / reversed (5-3) / out-of-range / non-numeric
selection is a 400; a non-PDF input is a 422. A selection may resolve to at
most 10 000 pages.
curl -X POST http://localhost:8000/api/v1/pdf/extract \
-H "X-API-Key: YOUR_KEY" \
-F "file=@report.pdf" \
-F "pages=1-3,5" \
--output report_pages.pdfSplit a PDF into one single-page PDF per page, bundled as a ZIP.
Authentication: Optional — X-API-Key or Authorization: Bearer; without credentials the request runs on the anonymous tier (see Authentication)
Request: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
file |
file | Yes | The source PDF (must be a .pdf) |
Response: 200 OK (application/zip) — one entry per page
(page_001.pdf, page_002.pdf, … zero-padded to the page count's width, so
entries sort correctly), _pages.zip suffix. A non-PDF input is a 422; a
document over 10 000 pages is a 400 (rejected before any work). The
assembled ZIP must also fit the tier output cap (413 otherwise).
curl -X POST http://localhost:8000/api/v1/pdf/split \
-H "X-API-Key: YOUR_KEY" \
-F "file=@report.pdf" \
--output report_pages.zipShrink a PDF toward a byte budget by recompressing its embedded raster images. Page count and every glyph are preserved; text/fonts/vector content are left intact. Honest by design — a PDF with no recompressible images comes back valid and unchanged-in-content (see the headers below), never a fake compression claim.
Authentication: Optional — X-API-Key or Authorization: Bearer; without credentials the request runs on the anonymous tier (see Authentication)
Request: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
file |
file | Yes | The source PDF (must be a .pdf) |
target_kb |
integer | Yes | Target output size in KB (> 0). The embedded images are recompressed toward this budget via binary search on a global JPEG quality. |
Response: 200 OK (application/pdf) — the compressed PDF as a download
(_compressed.pdf suffix), plus:
| Header | Description |
|---|---|
X-FileMorph-Achieved-Bytes |
Actual output size in bytes |
X-FileMorph-Converged |
true if the output reached the target within tolerance, else false |
X-FileMorph-Recompressible-Images |
How many embedded images the engine could recompress (0 ⇒ nothing to shrink) |
A target_kb above the tier output cap is a 413 (rejected before any work);
a value beyond a 2 GB sanity ceiling, or a non-PDF / corrupt input, is a
422 / 400. A text/vector-only PDF returns 200 with
X-FileMorph-Converged: false and X-FileMorph-Recompressible-Images: 0.
curl -X POST http://localhost:8000/api/v1/pdf/compress \
-H "X-API-Key: YOUR_KEY" \
-F "file=@scan.pdf" \
-F "target_kb=500" \
-D headers.txt \
--output scan_compressed.pdf
# headers.txt now carries X-FileMorph-Achieved-Bytes / -Converged /
# -Recompressible-ImagesConvert several files in one request. Returns a ZIP archive with all converted outputs.
Authentication: Optional — X-API-Key or Authorization: Bearer; without credentials the request runs on the anonymous tier (see Authentication)
Request: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
files |
files (≥1) | Yes | One or more files to convert |
target_formats |
string[] | Yes | Target format per file, repeated once per file in the same order (length must match files, otherwise 422) |
quality |
integer | No | Quality 1–100 (default 85). Applied uniformly. |
Response: 200 OK (application/zip, named filemorph-batch.zip) — archive with one entry per successful conversion. If at least one file fails, a manifest.json is added at archive root listing per-file results (success ZIP-only is preferred for all-success runs to keep the output clean). The X-FileMorph-Batch-* headers carry the counts and the failed files (see Batch headers); X-FileMorph-Batch-Failed above 0 means the ZIP holds a manifest.json.
A run with every file failing returns 422 Unprocessable Content with a JSON body listing per-file errors.
curl -X POST http://localhost:8000/api/v1/convert/batch \
-H "X-API-Key: YOUR_KEY" \
-F "files=@a.heic" -F "files=@b.png" -F "files=@c.gif" \
-F "target_formats=jpg" -F "target_formats=jpg" -F "target_formats=jpg" \
--output batch.zipCompress several files in one request. Same response shape as /convert/batch.
Authentication: Optional — X-API-Key or Authorization: Bearer; without credentials the request runs on the anonymous tier (see Authentication)
Request: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
files |
files (≥1) | Yes | One or more files to compress |
quality |
integer | No | Quality 1–100 (default 85). Mutually exclusive with target_size_kb. |
target_size_kb |
integer | No | Target size in KB (at least 5), applied to each file. JPEG/WebP/AVIF only — any other file in the batch fails on its own, with an error_message saying so. Mutually exclusive with quality. |
curl -X POST http://localhost:8000/api/v1/compress/batch \
-H "X-API-Key: YOUR_KEY" \
-F "files=@photo1.jpg" -F "files=@photo2.jpg" \
-F "quality=70" \
--output batch.zipReturns all supported conversion and compression formats.
Authentication: Not required
Response: 200 OK — JSON. An excerpt: conversions has one key per
source format, and only seven of them are shown here.
{
"conversions": {
"jpg": ["jpeg", "png", "webp", "bmp", "tiff", "tif", "gif", "ico", "avif", "pdf"],
"heic": ["jpg", "jpeg", "png", "webp", "bmp", "tiff", "tif", "gif", "ico", "avif", "pdf"],
"docx": ["pdf", "txt"],
"txt": ["pdf"],
"csv": ["xlsx", "json"],
"mp4": ["avi", "mov", "mkv", "webm", "flv", "wmv"],
"mp3": ["wav", "flac", "ogg", "m4a", "aac", "wma", "opus"]
},
"compression": {
"image": ["jpg", "jpeg", "png", "webp", "tiff", "tif", "avif"],
"video": ["mp4", "avi", "mov", "mkv", "webm"]
}
}Use this endpoint to populate format selection dropdowns in your application.
Health check for monitoring and load balancer probes.
Authentication: Not required
Response: 200 OK — JSON
{"status": "ok"}/api/v1/health is the unauthenticated liveness probe — it stays deliberately minimal
(no version or codec flags) so a public hit does not disclose deployment internals
(pentest finding PT-011). For readiness, use GET /api/v1/ready: it checks that the
database answers (reported as skipped when none is configured) and that the temp
directory is writable, e.g. {"status": "ready", "checks": {"database": "ok", "tempdir": "ok"}}, and returns 503 with "status": "not_ready" when a check
fails. It does not check ffmpeg — the startup log warns when ffmpeg is missing.
Public contact form, linked from the German Impressum as the second, fast-direct contact channel required by DDG §5 (ECJ C-298/07). Anonymous; works on the Community edition too — the message is emailed to the operator (recipient resolved from CONTACT_FORM_RECIPIENT_EMAIL → SMTP_REPLY_TO → SMTP_FROM_EMAIL), with Reply-To set to the submitter. The message is not persisted server-side — only an audit event with a hashed email + the visitor's locale is recorded.
Authentication: Not required · Rate limit: 5 / hour per IP
Request body (JSON): email (required), message (required, 20–5000 chars), name (optional, ≤120), subject (optional, ≤160). Anti-spam: a hidden honeypot field plus the rate limit — automated-looking submissions receive a normal 200 and are silently dropped.
Responses: 200 {"detail": "Message sent."} on success; 422 on validation errors; 429 when rate-limited; 502 if delivery fails (the UI then offers the direct mailto: fallback).
Every successful conversion / compression carries integrity and classification metadata in response headers. CORS-enabled deployments expose these to browser clients (see expose_headers in app/main.py).
| Header | Value | Set on |
|---|---|---|
X-Output-SHA256 |
Hex-encoded SHA-256 of the response body | every single-file /convert and /compress (not the batch ZIPs) |
X-Data-Classification |
One of public, internal, confidential, restricted |
every response — echoes the request header value, defaults to internal when absent (NEU-C.3 / BSI-style taxonomy) |
X-FileMorph-Achieved-Bytes |
Actual output size in bytes | on /compress calls with target_size_kb, and on /pdf/compress |
X-FileMorph-Final-Quality |
Quality value the binary search settled on (1–100) | only on /compress calls with target_size_kb |
X-FileMorph-Batch-Total, X-FileMorph-Batch-Succeeded, X-FileMorph-Batch-Failed |
File counts of the batch | every 200 from /convert/batch and /compress/batch |
X-FileMorph-Batch-Failures |
The failed files and why (see Batch headers) | batch 200 responses in which at least one file failed |
X-FileMorph-Error-Code |
A fixed code for the reason of an error (see Error codes) | the error responses listed there |
X-FileMorph-Warnings |
Comma-separated key=value tokens (see Conversion warnings) |
/convert DOCX → PDF, when the layout had to be simplified |
Retry-After |
Seconds the client should wait before retrying | on 503 Service Unavailable (global concurrency cap) and on every 429 except the rate limiter's (slowapi, see Rate Limiting) — e.g. the per-tier concurrency cap and the monthly call quota |
The X-Data-Classification value is also written to the audit-log entry for the request, so a downstream auditor can answer "what classification of data was processed in this call" from the database alone (see app/core/audit.py).
X-FileMorph-Batch-Failures lists each failed file as <name>|<reason>,
entries separated by ;. Name and reason are percent-encoded, so a | or ;
inside them cannot break the list — for example
two.png|Conversion%20failed.%20Verify%20the%20file%20is%20valid.. The header
is capped at about 4 KB: a longer list is cut and ends with the entry ...,
while manifest.json in the ZIP always lists every file. The three count
headers always carry the full totals. A batch in which every file failed
returns the 422 JSON body instead, without these headers.
Some error responses carry X-FileMorph-Error-Code, a fixed string a client
can branch on (the detail text may change):
| Code | Status | Meaning |
|---|---|---|
input_too_large |
413 |
A file is larger than your tier allows per file |
output_cap_exceeded |
413 |
The result is larger than your tier's output cap |
target_size_exceeds_cap |
413 |
target_size_kb (/compress, /compress/batch) or target_kb (/pdf/compress) is above your tier's output cap — rejected before any work |
decompression_bomb |
400 |
The image's dimensions exceed the decoder's safety limit (/convert, /compress) |
invalid_input |
400 |
A problem you can fix, named in detail — e.g. a Markdown, CSV or JSON file that isn't UTF-8 (/convert) |
invalid_page_selection |
400 |
/pdf/extract: the pages selection is invalid, or the PDF can't be read |
invalid_pdf |
400 |
/pdf/split, /pdf/compress: the PDF can't be read; /pdf/split also for a PDF with no pages or more than 10 000 |
The redaction endpoints add codes of their own, listed under
AI operations.
Errors without the header — for example validation errors (422), a blocked
file type (400 "File type not permitted."), rate limits and most 500s —
are identified by status and detail.
/convert sets X-FileMorph-Warnings when a DOCX → PDF conversion ran on the
pure-Python engine and its layout had to be simplified. The PDF is still
returned (200). The tokens, comma-separated:
engine=mammoth_fallback— the document needed LibreOffice, but the pure-Python engine ran;reason=soffice_unavailable(LibreOffice is not installed, e.g. in the slim image) orreason=soffice_runtime_error(LibreOffice failed) says why.simplified=<feature>— one per feature the pure-Python engine dropped or flattened:footnotes,endnotes,headers,footers,ole_objects,multi_section_layout,equations,multilevel_lists.fidelity=reduced— the pure-Python engine reported other simplifications.
For example engine=mammoth_fallback,reason=soffice_unavailable,simplified=footnotes.
The engine routing is described in formats.md.
Content-Disposition names the download after the uploaded file:
| Endpoint | Download name |
|---|---|
/convert |
<name>.<target_format>; <name>_pdfa.pdf for target_format=pdfa |
/compress, /pdf/compress |
<name>_compressed.<ext> |
/pdf/extract, /pdf/split |
<name>_pages.pdf, <name>_pages.zip |
/ai/redact/apply |
<name>.redacted.<ext> — <name>.redacted.txt for every text input |
/convert/batch, /compress/batch |
filemorph-batch.zip; the entries inside are named like single-file results |
The name is sanitised: accents and other combining marks are dropped
(Café → Cafe), and every character other than a letter, digit, space,
_, - or . is removed. It is capped at 200 characters by shortening only
<name>, so the extension and suffixes such as _compressed are kept.
Errors return JSON with a detail field:
{
"detail": "Conversion from 'jpg' to 'docx' is not supported."
}Two exceptions: a 429 from the rate limiter (slowapi) reads
{"error": "Rate limit exceeded: 10 per 1 minute"} — with the limit of
the route you called — and has no Retry-After header; a batch in which
every file failed returns 422 with {"summary": …, "files": […]}.
| HTTP Status | Meaning |
|---|---|
400 Bad Request |
Missing or malformed request data (e.g. filename without extension), or file content that has to be fixed first — e.g. a Markdown, CSV or JSON file that isn't UTF-8 (X-FileMorph-Error-Code: invalid_input; detail names the fix) |
401 Unauthorized |
An X-API-Key was sent but is not valid, or an endpoint that needs an account (/auth/me, /keys, /billing/*, …) got no valid Authorization: Bearer token. The file endpoints need no credentials: without them — or with an expired Bearer token — they run on the anonymous tier (see Authentication) |
403 Forbidden |
Authenticated but role/tier doesn't permit the action (e.g. non-admin hitting /cockpit/*) |
413 Content Too Large |
Request exceeds MAX_UPLOAD_SIZE_MB (default: 100 MB), a file exceeds your tier's size cap, or the output exceeds your tier's output cap |
415 Unsupported Media Type |
target_size_kb set on a lossless format (PNG/TIFF), or otherwise incompatible request shape |
422 Unprocessable Content |
Unsupported format combination, missing form field, target_formats count ≠ files count, or every file in a batch failed |
429 Too Many Requests |
Rate limit exceeded (see Rate Limiting section below), per-tier concurrency cap reached, monthly call quota used up, or too many rejected API keys from your IP (see Rate Limiting — fix the key; waiting won't help) |
500 Internal Server Error |
Conversion failed (e.g. corrupt file, missing binary) |
503 Service Unavailable |
Global concurrency cap reached (MAX_GLOBAL_CONCURRENCY). Response carries Retry-After. |
Every endpoint in a row has its own budget (/convert and /compress
allow 10 per minute each), and a path parameter does not split it:
DELETE /api/v1/keys/{key_id} has one budget, whichever key it names.
Most limits count per client IP address. The account endpoints for API
keys, billing and the email language count per signed-in account instead,
so colleagues behind one office IP don't share a budget. Every API
endpoint is listed, including the four that are deliberately not limited;
there is no catch-all limit, so the HTML pages and /static are not
rate-limited.
The client IP is the address uvicorn sees. Behind a reverse proxy that is
the visitor's address only when FORWARDED_ALLOW_IPS trusts the proxy —
and, if a CDN sits in front of the proxy, only when the proxy takes the
visitor's address from the CDN's header rather than passing on the CDN's
own. Otherwise all visitors share one budget per IP-counted endpoint (see
security-overview.md § Operational Hardening,
item 3).
| Endpoint | Limit | Counted per |
|---|---|---|
POST /api/v1/convert, POST /api/v1/compress |
10 / minute | IP |
POST /api/v1/convert/batch, POST /api/v1/compress/batch |
3 / minute | IP |
POST /api/v1/pdf/extract, POST /api/v1/pdf/split, POST /api/v1/pdf/compress |
10 / minute | IP |
POST /api/v1/ai/redact/detect |
20 / minute | IP |
POST /api/v1/ai/redact/apply |
10 / minute | IP |
GET /api/v1/formats |
120 / minute | IP |
GET /api/v1/health, GET /api/v1/ready |
30 / minute | IP |
POST /api/v1/auth/register, POST /api/v1/auth/login, POST /api/v1/auth/reset-password |
5 / minute | IP |
POST /api/v1/auth/forgot-password, POST /api/v1/auth/resend-verification |
3 / minute | IP |
POST /api/v1/auth/verify-email |
10 / minute | IP |
DELETE /api/v1/auth/account |
1 / minute | IP |
PUT /api/v1/auth/account/language |
10 / minute | account |
POST /api/v1/keys |
10 / minute | account |
GET /api/v1/keys |
120 / minute | account |
DELETE /api/v1/keys/{key_id} |
30 / minute | account |
POST /api/v1/billing/checkout/{tier}, POST /api/v1/billing/portal |
5 / minute | account |
GET /api/v1/cockpit/stats, GET /api/v1/cockpit/users, GET /api/v1/cockpit/timeseries, GET /api/v1/cockpit/usage-summary |
30 / minute | IP |
PATCH /api/v1/cockpit/users/{user_id}, DELETE /api/v1/cockpit/users/{user_id} |
10 / minute | IP |
POST /api/v1/contact |
5 / hour | IP |
POST /api/v1/auth/refresh |
not limited — a request costs one signature check, and a failed refresh signs the web user out, so a shared limit would let junk requests sign out everyone behind an IP | — |
GET /api/v1/auth/me |
not limited — the token check does the work before any limit could apply, and the web UI calls it on every page view | — |
POST /api/v1/billing/webhook |
not limited — Stripe signs every delivery and retries failed ones | — |
GET /api/v1/metrics |
not limited — Prometheus scrape target; restrict it at your proxy (see security-overview.md) |
— |
When exceeded, the response is 429 Too Many Requests. For higher
limits, self-host your own instance and adjust the decorators in
app/api/routes/*.py (slowapi @limiter.limit("…/minute")).
An X-API-Key the server does not accept is answered with 401 for the
first 30 attempts per minute per IP address, counted across every
endpoint that takes the header. After that, each further rejected key
from that address gets 429 Too Many Requests, with a Retry-After
header (seconds until the minute is over) and this body:
{
"detail": "Too many invalid API key attempts. Try again later."
}A valid key is never refused and never counts, and requests without
X-API-Key are not affected. The budget is set in
app/core/rate_limit.py.
Authenticated users are also limited per calendar month, independently of the per-IP rate limits above:
| Tier | Monthly API calls |
|---|---|
| Anonymous | n/a (per-IP rate-limit only) |
| Free | 1,000 |
| Pro | 25,000 |
| Business | 200,000 |
| Enterprise | unlimited |
The gate counts every successful POST /api/v1/convert,
/convert/batch, /compress, /compress/batch, /pdf/extract,
/pdf/split, and /pdf/compress as one call. A batch with 25
files counts as 1 call (matching the pricing-page wording "API calls
per month"). Failed conversions do not count toward the quota.
When the limit is reached, the response is 429 Too Many Requests
with a Retry-After header in seconds pointing at the start of the
next calendar month, and a body explaining the limit:
{
"detail": "Monthly API call limit reached (25000 per month for tier 'pro'). Quota resets 2026-06-01T00:00:00+00:00. Upgrade your plan or wait until the reset to continue."
}The quota window is calendar-month UTC — the counter resets at
00:00 UTC on the 1st of every month. The pricing page advertises
identical figures; this gate is the runtime side of that promise.
FileMorph auto-generates interactive API documentation:
- Swagger UI:
http://localhost:8000/docs - ReDoc:
http://localhost:8000/redoc - OpenAPI JSON:
http://localhost:8000/openapi.json
The Swagger UI lets you test all endpoints directly in the browser.
$ch = curl_init('http://localhost:8000/api/v1/convert');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-API-Key: YOUR_KEY'],
CURLOPT_POSTFIELDS => [
'file' => new CURLFile('/path/to/photo.heic', 'image/heic', 'photo.heic'),
'target_format' => 'jpg',
'quality' => '85',
],
]);
$result = curl_exec($ch);
file_put_contents('/path/to/photo.jpg', $result);const FormData = require('form-data');
const fs = require('fs');
const axios = require('axios');
const form = new FormData();
form.append('file', fs.createReadStream('document.docx'));
form.append('target_format', 'pdf');
const response = await axios.post(
'http://localhost:8000/api/v1/convert',
form,
{
headers: { ...form.getHeaders(), 'X-API-Key': 'YOUR_KEY' },
responseType: 'arraybuffer',
}
);
fs.writeFileSync('document.pdf', response.data);using var client = new HttpClient();
client.DefaultRequestHeaders.Add("X-API-Key", "YOUR_KEY");
using var form = new MultipartFormDataContent();
form.Add(new StreamContent(File.OpenRead("photo.heic")), "file", "photo.heic");
form.Add(new StringContent("jpg"), "target_format");
var response = await client.PostAsync(
"http://localhost:8000/api/v1/convert", form);
await File.WriteAllBytesAsync("photo.jpg", await response.Content.ReadAsByteArrayAsync());