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 |
Either header satisfies the auth requirement on /convert, /compress, and their /batch variants. /health and /formats are public; the auth-flow endpoints (/api/v1/auth/*, /api/v1/keys, /api/v1/billing/*) require a JWT.
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.
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 JWT_SECRET, STRIPE_SECRET_KEY). Without those, they return 503 Service Unavailable. 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). |
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. |
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. Requires re-confirmation: current password, registered email, and the literal string DELETE. Free-tier accounts only — accounts with a Stripe customer ID return 409 and route to privacy@filemorph.io for the manual paid-tier path (HGB §257 / AO §147 retention). |
API keys (/api/v1/keys)
| Method + Path | Auth | Purpose |
|---|---|---|
POST /api/v1/keys |
Bearer | Create a new API key bound to the authenticated user. Plaintext key is shown exactly once in the response. |
GET /api/v1/keys |
Bearer | List the user's keys (id, name, prefix, created, last-used). |
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; an auth.billing.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.
Convert a file from one format to another.
Authentication: Required (X-API-Key header)
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 formats (JPEG, WebP, video) |
Response: 200 OK — the converted file as a download
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: Required (X-API-Key header)
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. Activates binary-search-on-quality (JPEG/WebP only). Mutually exclusive with quality |
Supported formats: JPG, JPEG, PNG, WebP, TIFF · MP4, MOV, AVI, MKV, WebM
target_size_kb is JPEG/WebP only — PNG/TIFF are lossless and quality does not control size meaningfully. Sending target_size_kb with a PNG returns 415.
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. 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 |
Convert several files in one request. Returns a ZIP archive with all converted outputs.
Authentication: Required (X-API-Key or Authorization: Bearer)
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. Either one value (applied to all) or one per file (length must match files) |
quality |
integer | No | Quality 1–100 (default 85). Applied uniformly. |
Response: 200 OK (application/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).
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" \
--output batch.zipCompress several files in one request. Same response shape as /convert/batch.
Authentication: Required
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 | Per-file target size. 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
{
"conversions": {
"jpg": ["png", "webp", "bmp", "tiff", "gif"],
"heic": ["jpg", "png", "webp"],
"docx": ["pdf", "txt"],
"txt": ["pdf"],
"csv": ["xlsx", "json"],
"mp4": ["avi", "mov", "mkv", "webm"],
"mp3": ["wav", "flac", "ogg", "m4a"]
},
"compression": {
"image": ["jpg", "jpeg", "png", "webp", "tiff"],
"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 operational state (database / temp-dir reachability,
ffmpeg-on-PATH), use GET /api/v1/ready.
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 /convert, /compress, and their batch variants |
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 | only on /compress calls with target_size_kb |
X-FileMorph-Final-Quality |
Quality value the binary search settled on (1–100) | only on /compress calls with target_size_kb |
Retry-After |
Seconds the client should wait before retrying | only on 503 Service Unavailable (concurrency cap) |
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).
All errors return JSON with a detail field:
{
"detail": "Conversion from 'jpg' to 'docx' is not supported."
}| HTTP Status | Meaning |
|---|---|
400 Bad Request |
Missing or malformed request data (e.g. filename without extension) |
401 Unauthorized |
Missing or invalid X-API-Key / Authorization: Bearer |
403 Forbidden |
Authenticated but role/tier doesn't permit the action (e.g. non-admin hitting /cockpit/*) |
413 Content Too Large |
File exceeds MAX_UPLOAD_SIZE_MB (default: 100 MB) |
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, or every file in a batch failed |
429 Too Many Requests |
Rate limit exceeded (see Rate Limiting section below) |
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. |
Per-route limits (per IP address):
| Endpoint | Limit |
|---|---|
POST /api/v1/convert |
10 / minute |
POST /api/v1/convert/batch |
3 / minute |
POST /api/v1/compress |
10 / minute |
POST /api/v1/compress/batch |
3 / minute |
GET /api/v1/health, GET /api/v1/ready |
30 / minute |
GET /api/v1/formats |
120 / minute |
Auth endpoints (/api/v1/auth/*) |
3–5 / minute |
Billing endpoints (/api/v1/billing/*) |
5 / minute |
POST /api/v1/contact |
5 / hour |
| Default (other routes) | 60 / minute |
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")).
Authenticated users on a paid tier 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 | 500 |
| Pro | 10 000 |
| Business | 100 000 |
| Enterprise | unlimited |
The gate counts every successful POST /api/v1/convert,
/convert/batch, /compress, and /compress/batch 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 (10000 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());