A workflow-oriented walkthrough for integrating FileMorph. Pairs with
api-reference.md — that doc lists every endpoint
and field; this one shows how the pieces fit together for real
integrations (auth, batches, error handling, quotas, CORS).
Audience: developers building against FileMorph, whether against
the hosted API at https://api.filemorph.io or a self-hosted instance.
Base URL convention: examples below use https://api.filemorph.io.
If you're running self-hosted, substitute http://localhost:8000
(default) or your own host. The API path prefix /api/v1/ is the same
either way.
Versioning: the API path is versioned. Breaking changes go to
/api/v2/ rather than mutating /api/v1/. Track CHANGELOG.md
for release notes.
The fastest path to your first conversion: no account, no API key.
curl -X POST https://api.filemorph.io/api/v1/convert \
-F "file=@photo.heic" \
-F "target_format=jpg" \
-F "quality=90" \
--output photo.jpgThat's it. Anonymous calls work — they just have tighter limits:
- 30 MB per file
- 1 file per request (the batch endpoints accept a single file
from anonymous callers and reject two or more with
400) - 10 requests/min on
/convertand on/compress, counted per client IP
For larger files and batches, get an account. The per-minute rate limit stays the same on every tier — see Tier Quotas & Discovery.
FileMorph accepts two credentials on the same endpoints:
| Credential | Header | Best for | Lifetime |
|---|---|---|---|
| JWT | Authorization: Bearer <token> |
Browser apps, SPAs, short-lived sessions | 15 min access, 30 day refresh |
| API key | X-API-Key: <key> |
Backend integrations, CLIs, cron jobs | Until revoked |
Both can be sent on the same request. The server prefers Bearer; if
that fails (expired, malformed) it falls back to X-API-Key. Anonymous
callers are also accepted — you get the anonymous tier.
┌──────────┐ POST /auth/register or /auth/login ┌────────────┐
│ Client │ ────────────────────────────────────► │ FileMorph │
│ │ ◄──── { access_token, refresh_token } │ │
└──────────┘ └────────────┘
│ ▲
│ POST /api/v1/convert (Authorization: Bearer …) │
├────────────────────────────────────────────────────┤
│ ◄──── 200 OK + file │
│ │
│ ⏱ 15 min later: access token expires │
│ │
│ POST /api/v1/convert (expired bearer) │
│ ◄──── no 401: runs on the anonymous tier │
│ │
│ POST /auth/refresh { refresh_token } │
│ ◄──── { access_token, refresh_token } (rotated) │
│ │
│ POST /api/v1/convert (new bearer) │
│ ◄──── 200 OK │
# Register — returns tokens immediately, no email confirmation gate
curl -X POST https://api.filemorph.io/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"a-strong-password"}'
# Response (201 Created)
# { "access_token": "eyJ…", "refresh_token": "eyJ…", "token_type": "bearer" }
# Or log in if you already have an account
curl -X POST https://api.filemorph.io/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"a-strong-password"}'import requests
def login(email: str, password: str) -> dict:
r = requests.post(
"https://api.filemorph.io/api/v1/auth/login",
json={"email": email, "password": password},
timeout=10,
)
r.raise_for_status()
return r.json() # { "access_token": ..., "refresh_token": ..., "token_type": "bearer" }async function login(email, password) {
const r = await fetch("https://api.filemorph.io/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!r.ok) throw new Error(`Login failed: ${r.status}`);
return r.json();
}Before the access token expires, exchange your refresh token for a fresh access token. Refresh tokens rotate — store the new one and discard the old.
Don't wait for a 401. Endpoints that need an account — for example
/auth/me, /keys and /billing/checkout — answer an expired token with
401. The file endpoints
(/convert, /compress, the batch, /pdf/* and /ai/* routes) ignore an
expired or invalid token and run the request on the anonymous tier, with its
smaller limits: a small file still converts, just not on your account, while
a batch of two or more files or a larger file fails with a limit error, and
/ai/redact/apply answers 403. Track the expiry on the
client (the token's exp claim, 15 minutes after issue) and refresh a
minute early — or use an API key for unattended jobs.
curl -X POST https://api.filemorph.io/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token":"eyJ…"}'API keys are easier for backend integrations: they don't expire, you
don't deal with refresh logic, and they're managed from the dashboard
(/dashboard) or via the API.
# Requires a valid bearer token in scope
curl -X POST https://api.filemorph.io/api/v1/keys \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"label":"prod-pipeline"}'
# Response (201 Created) — the plaintext "key" is shown ONCE
# {
# "id": "9f5a…",
# "label": "prod-pipeline",
# "created_at": "2026-04-27T10:00:00Z",
# "last_used_at": null,
# "is_active": true,
# "key": "<43 URL-safe characters, no prefix>"
# }key field is revealed exactly once, in the
creation response. The server stores only its SHA-256 hash. If you
lose it, revoke the key (DELETE /api/v1/keys/{id}) and create a
new one.
Storage advice: load keys from environment variables, not from checked-in config:
read -rs FILEMORPH_API_KEY # paste the key; it is not echoed or kept in shell history
export FILEMORPH_API_KEYimport os, requests
key = os.environ["FILEMORPH_API_KEY"]
requests.post(url, headers={"X-API-Key": key}, files=…)Never commit a .env containing real keys. Add .env to .gitignore
and use a secrets manager (AWS Secrets Manager, Doppler, Vault, etc.)
for production deploys.
The full field reference lives in api-reference.md.
This section focuses on handling the response correctly.
import requests, re
def convert(path: str, target_format: str, key: str, quality: int = 85) -> tuple[bytes, str]:
with open(path, "rb") as f:
r = requests.post(
"https://api.filemorph.io/api/v1/convert",
headers={"X-API-Key": key},
files={"file": (path, f)},
data={"target_format": target_format, "quality": quality},
timeout=60,
stream=True, # don't buffer in memory for large outputs
)
r.raise_for_status()
# Server sets the filename in Content-Disposition; parse it for
# display, but you can save under any name you want.
cd = r.headers.get("Content-Disposition", "")
m = re.search(r'filename="([^"]+)"', cd)
ext = "pdf" if target_format == "pdfa" else target_format # PDF/A is a .pdf
suggested = m.group(1) if m else f"output.{ext}"
return r.content, suggestedResponse anatomy:
Content-Type: application/octet-streamContent-Disposition: attachment; filename="<original-stem>.<target-ext>"(PDF/A is the exception:target_format=pdfareturns<original-stem>_pdfa.pdf, since PDF/A is a PDF profile, not a file extension). The stem is sanitised — accents are dropped and characters other than letters, digits, spaces,_,-and.removed — and the whole name is capped at 200 characters by shortening the stem, so the extension survives (details inapi-reference.md).- Body: the converted file, raw bytes.
Quality semantics vary by format:
- JPEG, WebP, AVIF —
quality(1–100) is passed to the encoder as its quality setting. Default85is a good general trade-off. - Video —
qualityis mapped onto the encoder's rate control: CRF 40 → 18 for H.264, CRF 45 → 18 for VP9 (WebM), qscale 31 → 2 for AVI and WMV. The table is informats.md. - PNG, TIFF, BMP, GIF, ICO —
qualityis ignored on/convert. (On/compress, PNG turns quality into the zlib compression level, which changes speed and size, never the pixels.) - Audio —
qualitysets the bitrate: the VBR level for MP3 and OGG, 64–256 kbit/s for AAC/M4A, 32–192 kbit/s for Opus. WMA is always 128 kbit/s, and WAV and FLAC are lossless and ignore it. There is no separate bitrate parameter.
Sometimes you don't care about quality — you care about a hard size
cap. Email gateways with 25 MB attachment limits, embedded systems
with constrained storage, archive jobs trying to fit a year of photos
into a fixed budget. For these, send target_size_kb instead of
quality:
curl -X POST https://api.filemorph.io/api/v1/compress \
-H "X-API-Key: $FILEMORPH_API_KEY" \
-F "file=@photo.jpg" \
-F "target_size_kb=500" \
-D headers.txt \
--output photo_capped.jpg
# Inspect achieved size:
grep -i x-filemorph headers.txt
# X-FileMorph-Achieved-Bytes: 489214
# X-FileMorph-Final-Quality: 72The server runs a binary search on quality (1–100) and stops at the first output within ±3 % of the target. The result is never more than 3 % over the target (except in the below-floor case below), but it can be smaller: an image that already fits at quality 95 comes back at quality 95, and after eight steps the search returns its best fit.
Constraints:
- JPEG, WebP and AVIF only. PNG and TIFF are lossless — quality does
not control size meaningfully. Sending
target_size_kbwith a PNG returns415. AVIF encoding is CPU-heavy and the search re-encodes several times, so a large AVIF photo can take a minute or more. - At least 5. A smaller
target_size_kbis rejected with422. - Mutually exclusive with
quality. Send one or the other; sending both returns400. - Tier-capped.
target_size_kblarger than your tier's output cap returns413before any encoding work, so a typo doesn't burn CPU. - Below-floor edge case. If even quality
1exceeds the target (the input simply can't be that small without resizing), the server returns the smallest possible output anyway — status200, andX-FileMorph-Achieved-Bytesreveals the actual size so your client can react.
Python:
import requests
def compress_to_target(path: str, target_kb: int, key: str) -> tuple[bytes, int]:
with open(path, "rb") as f:
r = requests.post(
"https://api.filemorph.io/api/v1/compress",
headers={"X-API-Key": key},
files={"file": f},
data={"target_size_kb": target_kb},
timeout=300, # large AVIF inputs can take a minute or more
)
r.raise_for_status()
achieved = int(r.headers["X-FileMorph-Achieved-Bytes"])
return r.content, achievedJavaScript (browser):
const fd = new FormData();
fd.append("file", file);
fd.append("target_size_kb", "500");
const res = await fetch("https://api.filemorph.io/api/v1/compress", {
method: "POST",
headers: { "X-API-Key": API_KEY },
body: fd,
});
const blob = await res.blob();
const achieved = parseInt(res.headers.get("X-FileMorph-Achieved-Bytes"), 10);
console.log(`Output: ${(achieved / 1024 / 1024).toFixed(2)} MB`);The batch endpoint accepts the same parameter and applies the target
to each file in the request. Only JPEG, WebP and AVIF files can take
it: any other file in the batch fails on its own (its error_message
says so), and the rest are still compressed:
curl -X POST https://api.filemorph.io/api/v1/compress/batch \
-H "X-API-Key: $FILEMORPH_API_KEY" \
-F "files=@one.jpg" \
-F "files=@two.jpg" \
-F "target_size_kb=300" \
--output capped.zipPOST /api/v1/convert/batch and POST /api/v1/compress/batch cut
request overhead when you have many files. The wire format is
straightforward, but the response branches three ways depending on
which files succeeded.
curl -X POST https://api.filemorph.io/api/v1/convert/batch \
-H "X-API-Key: $FILEMORPH_API_KEY" \
-F "files=@one.jpg" \
-F "files=@two.jpg" \
-F "files=@three.heic" \
-F "target_formats=png" \
-F "target_formats=png" \
-F "target_formats=jpg" \
--output result.zipfiles and target_formats are repeated multipart fields. They must
have the same length and the same order — target_formats[i] is
the desired output for files[i]. Mismatch → 422 Unprocessable Entity; no file is converted.
| Outcome | Status | Content-Type | Body |
|---|---|---|---|
| All files succeeded | 200 OK |
application/zip |
ZIP of converted files (no manifest) |
| Some succeeded, some failed | 200 OK |
application/zip |
ZIP of successful files plus manifest.json |
| Every file failed | 422 Unprocessable Entity |
application/json |
{ summary, files[] } (no ZIP) |
Your client has to inspect Content-Type before parsing. To tell the two
200 shapes apart, read the X-FileMorph-Batch-Failed header: above
0, the ZIP carries manifest.json. Don't go by the file name — in an
all-success ZIP a converted file may itself be called manifest.json
(see Duplicate filenames). Every 200 batch
response also carries X-FileMorph-Batch-Total,
X-FileMorph-Batch-Succeeded and, when a file failed,
X-FileMorph-Batch-Failures (format in
api-reference.md).
import io, json, zipfile, requests
def batch_convert(paths: list[str], targets: list[str], key: str) -> dict:
files = [("files", (p, open(p, "rb"))) for p in paths]
data = [("target_formats", t) for t in targets]
r = requests.post(
"https://api.filemorph.io/api/v1/convert/batch",
headers={"X-API-Key": key},
files=files,
data=data,
timeout=300,
)
ctype = r.headers.get("Content-Type", "")
if r.status_code == 422 and "application/json" in ctype:
# Shape C: everything failed
return {"status": "all_failed", "body": r.json()}
if r.status_code == 200 and "application/zip" in ctype:
zf = zipfile.ZipFile(io.BytesIO(r.content))
if int(r.headers.get("X-FileMorph-Batch-Failed", "0")) > 0:
# Shape B: partial success — manifest tells you which is which
manifest = json.loads(zf.read("manifest.json"))
return {"status": "partial", "zip": zf, "manifest": manifest}
# Shape A: clean all-success
return {"status": "ok", "zip": zf}
r.raise_for_status()
raise RuntimeError(f"Unexpected response: {r.status_code} {ctype}")async function batchConvert(fileList, targets, apiKey) {
const fd = new FormData();
for (const f of fileList) fd.append("files", f);
for (const t of targets) fd.append("target_formats", t);
const r = await fetch("https://api.filemorph.io/api/v1/convert/batch", {
method: "POST",
headers: { "X-API-Key": apiKey },
body: fd,
});
const ctype = r.headers.get("Content-Type") || "";
if (r.status === 422 && ctype.includes("application/json")) {
return { status: "all_failed", body: await r.json() };
}
if (r.ok && ctype.includes("application/zip")) {
const blob = await r.blob();
// Failed > 0: the ZIP carries manifest.json. Use JSZip or the browser's
// built-in DecompressionStream to read it and extract files.
const failed = Number(r.headers.get("X-FileMorph-Batch-Failed") || 0);
return { status: failed > 0 ? "partial" : "ok", blob };
}
throw new Error(`Unexpected response: ${r.status} ${ctype}`);
}When manifest.json is present (partial-success ZIP) or as the body
of a 422, it has this shape — name is the output file's name, and
the 422 body leaves out size_out:
{
"summary": {
"operation": "convert",
"total": 3,
"succeeded": 2,
"failed": 1,
"total_bytes_in": 160432128,
"total_bytes_out": 1572864,
"duration_ms": 412
},
"files": [
{ "name": "one.png", "status": "ok", "size_in": 1048576, "size_out": 786432, "error_message": "" },
{ "name": "two.png", "status": "ok", "size_in": 2097152, "size_out": 786432, "error_message": "" },
{ "name": "three.jpg", "status": "error", "size_in": 157286400, "size_out": 0, "error_message": "Output too large (412 MB > 400 MB cap). Try WebP/AVIF or upgrade your plan." }
]
}Common per-file error_message values:
"Output too large (N MB > M MB cap). Try WebP/AVIF or upgrade your plan."— the output (N MB) exceeded your tier's output cap (M MB). On/compress/batchthe hint reads"Lower the quality or upgrade your plan.""Conversion from 'docx' to 'mp4' is not supported."— no converter for that pair."File type not permitted."— magic-byte filter rejected the upload (executable / script content)."The file is not UTF-8 text. Re-save it as UTF-8 …"— a Markdown, CSV or JSON file in another encoding (e.g. Excel's default CSV export on Windows). Single-file/convertreturns the same message as a400withX-FileMorph-Error-Code: invalid_input."Conversion failed. Verify the file is valid."(compress:"Compression failed. …") — any other error while processing that file, e.g. corrupt content. The details stay in the server log.
If two inputs convert to the same output name (a.jpg and a.JPG
both → a.png), the second one gets _1 appended (a_1.png), the
third _2 (a_2.png), and so on. The order in files[] decides
which wins the unsuffixed name.
A name already in the ZIP is never reused. An input whose own output
name is a_1.png becomes a_1_1.png if an earlier duplicate took
a_1.png, and an output named manifest.json becomes
manifest_1.json when the ZIP carries the batch report (at least one
file failed). Use the X-FileMorph-Batch-Failed header, not the file
name, to tell whether a report is present.
| Tier | Max file size | Max files / batch | Output cap | Concurrent requests | API calls / month |
|---|---|---|---|---|---|
| anonymous | 30 MB | 1 | 90 MB | 1 | n/a |
| free | 100 MB | 10 | 300 MB | 1 | 1,000 |
| pro | 250 MB | 50 | 400 MB | 3 | 25,000 |
| business | 500 MB | 150 | 500 MB | 6 | 200,000 |
| enterprise | 500 MB | 250 | 500 MB | 10 | unlimited |
The per-minute rate limit is not part of the tier: it is counted per
client IP and per endpoint, the same for every caller — 10/min on
/convert and /compress, 3/min on /convert/batch and
/compress/batch. An account raises the limits in the table, not the
requests per minute.
Every instance also caps each whole request at MAX_UPLOAD_SIZE_MB
(default 100 MB; a batch is one request). The tiers above anonymous
come with an account, and accounts need the Cloud Edition database. A
self-hosted Community Edition instance has none, so every caller there
gets the anonymous row — API keys included — unless the operator gives
the keys a tier with API_KEYS_FILE_TIER and raises
MAX_UPLOAD_SIZE_MB to match (see
self-hosting).
Exact values live in app/core/quotas.py —
the same source the server enforces and the /pricing page renders —
and may be tuned over time. /api/v1/auth/me tells you which tier
you are on.
curl https://api.filemorph.io/api/v1/auth/me \
-H "Authorization: Bearer $ACCESS_TOKEN"
# {
# "id": "9f5a-…",
# "email": "you@example.com",
# "tier": "pro",
# "role": "user",
# "created_at": "2026-01-15T08:30:00Z"
# }Note: /auth/me only accepts JWT, not X-API-Key. If you only have
an API key in your client, store the tier alongside the key when you
mint it.
- File size exceeded →
413 Request Entity Too Largewith"File too large (N MB max for your plan). Upgrade for larger files."(anonymous callers are pointed to the free tier instead). - Batch size exceeded →
400 Bad Requestwith"Batch size N exceeds tier limit of M." - Output cap exceeded →
413with"Output too large (N MB > M MB cap)."plus a hint — on/convert, try a more efficient target (WebP/AVIF for images, FLAC for audio) or upgrade; on/compress,"Lower the quality or upgrade your plan."Note this is checked after the conversion runs, so the server has already spent the CPU time; the request counts toward the per-minute rate limit but not toward your monthly API calls. - Concurrent requests exceeded →
429 Too Many Requestswith aRetry-Afterheader (seconds). - Monthly API calls used up →
429with aRetry-Afterheader counting down to 00:00 UTC on the 1st of next month. - Rate limit exceeded →
429without aRetry-Afterheader — see backoff guidance below. - Too many rejected API keys from your IP address (more than 30 in a
minute) →
429with aRetry-Afterheader and{"detail": "Too many invalid API key attempts. Try again later."}. The key is the problem: fix it — after the wait, a wrong key gets401again.
A 5 MB JPEG re-encoded to PNG can balloon to 50+ MB; MP3 → WAV is ~11×. Without an output cap, a single request could push gigabytes out of the server. The cap depends on the tier (see the table above) and is enforced post-conversion. To stay under it: pick modern lossy formats (WebP, AVIF, MP3 at lower bitrate) where you have a choice.
Most 4xx and 5xx responses use the FastAPI JSON shape:
{ "detail": "Human-readable error message." }For validation failures (422) you also get a structured errors
array describing each invalid field. Two responses look different:
- A
429from the rate limiter (slowapi) reads{"error": "Rate limit exceeded: 10 per 1 minute"}, with the limit of the route you called. - A batch where every file failed returns
422with the manifest shape,{ "summary": …, "files": […] }(see above).
| Code | Meaning | Retry? |
|---|---|---|
400 |
Bad request, e.g. filename without extension, blocked file type, a text file that isn't UTF-8, batch over tier | No — fix the request |
401 |
An X-API-Key that isn't valid, or a missing or expired JWT on an account endpoint (/auth/me, /keys, /billing/*). File endpoints never answer 401 for a missing or expired JWT — they run anonymously |
No — check the key or refresh the JWT |
403 |
Authenticated but not allowed (admin-only routes) | No |
409 |
Conflict — email already registered, or the account already has 25 active API keys (revoke one first) | No |
413 |
File or output exceeds cap | No — reduce size or upgrade |
415 |
target_size_kb on a lossless format (PNG/TIFF) |
No — use quality instead |
422 |
Missing or invalid field, target_formats count ≠ files count, unsupported format pair, or batch where every file failed |
No — fix the request (for a batch, check each file's error_message) |
429 |
Rate limit, concurrent-request cap, monthly API calls used up, or too many rejected API keys | Yes — after Retry-After if sent (monthly quota: from the 1st of next month); rejected keys: fix the key instead |
5xx |
Server error (503: server at capacity) |
Yes, with backoff |
Only 429 and 5xx are worth retrying. 4xx other than 429 means
the request is wrong; retrying without changes is a waste.
import time, requests
def with_backoff(fn, max_attempts: int = 4, max_wait: float = 120.0):
delay = 1.0
for attempt in range(max_attempts):
r = fn()
if r.status_code != 429 and r.status_code < 500:
return r
if "Retry-After" in r.headers:
wait = float(r.headers["Retry-After"]) # seconds
elif r.status_code == 429:
wait = 60.0 # per-minute rate limit: wait out the window
else:
wait, delay = delay, delay * 2 # other 5xx: 1s → 2s → 4s
if attempt == max_attempts - 1 or wait > max_wait:
return r # out of attempts, or a wait too long to sit out
time.sleep(wait)A 429 from the rate limiter has no Retry-After header: wait
out the one-minute window. The 429s of the concurrent-request cap
and of the monthly API calls, and the 503 of a server at capacity,
send Retry-After in seconds — wait that long. A used-up monthly quota
only resets at 00:00 UTC on the 1st of next month, so its
Retry-After is too long to sleep through: the example gives up once
a wait exceeds max_wait. Other 5xx back off exponentially. The
429 for too many rejected API keys also sends Retry-After, but
waiting only turns it back into a 401 — check the key instead.
Before any converter runs, the server peeks at the first few bytes and rejects anything that looks like an executable or script:
MZ(Windows PE / DOS executables)\x7fELF(Unix binaries)#!/(shebang scripts)<?php(PHP source)
The file extension doesn't matter: a .jpg that starts with MZ is
rejected too. (A request that fails earlier — an unsupported format
pair is a 422 — never reaches this check.) Rejected with
400 "File type not permitted." — there's no retry that helps.
Use this to populate dropdowns dynamically and to skip uploads for unsupported pairs.
curl https://api.filemorph.io/api/v1/formats
# {"conversions": {"jpg": ["jpeg", "png", "webp", …], …},
# "compression": {"image": ["jpg", …], "video": ["mp4", …]}}import requests
formats = requests.get("https://api.filemorph.io/api/v1/formats").json()
def can_convert(src: str, tgt: str) -> bool:
return tgt in formats["conversions"].get(src, [])const formats = await fetch("https://api.filemorph.io/api/v1/formats").then(r => r.json());The endpoint needs no API key and does not count toward your monthly
API calls, but it is rate-limited: 120 requests/min per client IP.
Cache the response on the client — an hour or so is fine — rather
than fetching it before every upload. See formats.md
for the human-readable catalogue.
Most integrations hit the API from a backend, where CORS doesn't
apply. The CORS section matters only when a browser is calling
the API on a different origin — for example, a SPA on
https://yourapp.com calling https://api.filemorph.io.
The browser sends an OPTIONS preflight before the real request.
The server responds with the allowed origin, methods, and exposed
headers. If the preflight fails, the real request is never sent —
you'll see a CORS error in the dev console with no network tab
entry for the actual upload.
Content-Disposition is in the server's expose_headers list, so
this works:
const res = await fetch(uploadUrl, { method: "POST", body: fd });
const cd = res.headers.get("Content-Disposition"); // "attachment; filename=\"photo.png\""If you forget the cross-origin allowlist on a self-hosted instance,
the browser will hide the header silently and you'll fall back to
"result" as the filename. This is a frequent source of "downloads
have no extension" bugs in self-hosted setups.
Set the CORS_ORIGINS environment variable to the front origin(s)
that should be allowed:
export CORS_ORIGINS="https://yourapp.com,https://www.yourapp.com"See self-hosting.md for the full deployment
context including reverse proxy and API_BASE_URL split-domain
deployments.
Every request blocks until the conversion finishes. There's no async job queue, no polling endpoint, no webhook callback. Plan your client timeouts accordingly:
- Image conversion: <2 s typical
- AVIF target-size compression: can take a minute or more for large photos (AV1 encoding is CPU-heavy and the search re-encodes several times)
- Document conversion: 1–5 s
- Audio (re-encode): 2–10 s
- Video (FFmpeg): can take 30+ seconds for large inputs. The
server stops a single ffmpeg run (audio or video) after
MEDIA_SUBPROCESS_TIMEOUT_SECONDS, default 600 s; the request then fails with500(in a batch, only that file fails)
Use a generous client timeout (requests.post(..., timeout=300)) for
video and AVIF, and avoid wrapping the call in tight per-request UI
feedback.
There is no Idempotency-Key header. Retrying a successful upload
runs the conversion again and returns equivalent output. Conversions
are deterministic for a given (file, target_format, quality) tuple,
so this is safe but wasteful — design your retry logic to only fire
on errors, not on network blips that may have actually succeeded.
The server runs only as many conversions at once as its deploy-time
limit allows (MAX_GLOBAL_CONCURRENCY, default 4), shared by all
callers; past it you get 503. Within that, you can keep your tier's
number of concurrent requests in flight (see the table above) and
send up to 10 requests/min each to /convert and /compress — one
every 6 seconds, per client IP, on every tier. Beyond either of those
two limits you'll start seeing 429s.
api-reference.md— per-endpoint reference (fields, status codes, language snippets)formats.md— supported conversion pairs and compression formatsinstallation.md— local setupself-hosting.md— production deployment, reverse proxy, env varsCHANGELOG.md— release notes- Swagger UI — auto-generated OpenAPI spec for machine-readable exploration
Bug reports & feature requests: github.com/MrChengLen/FileMorph/issues