Skip to content

Latest commit

 

History

History
825 lines (661 loc) · 31.7 KB

File metadata and controls

825 lines (661 loc) · 31.7 KB

API Usage Guide

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.


Quickstart — Anonymous in 30 Seconds

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.jpg

That'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 /convert and 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.


Authentication — JWT vs. X-API-Key

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.

Auth flow

┌──────────┐  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 and log in

# 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();
}

Refresh the access token

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…"}'

Generate an API key

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

⚠️ The plaintext 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_KEY
import 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.


Single-File Conversion

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, suggested

Response anatomy:

  • Content-Type: application/octet-stream
  • Content-Disposition: attachment; filename="<original-stem>.<target-ext>" (PDF/A is the exception: target_format=pdfa returns <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 in api-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. Default 85 is a good general trade-off.
  • Video — quality is 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 in formats.md.
  • PNG, TIFF, BMP, GIF, ICO — quality is ignored on /convert. (On /compress, PNG turns quality into the zlib compression level, which changes speed and size, never the pixels.)
  • Audio — quality sets 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.

Compress to a Target Size

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

The 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_kb with a PNG returns 415. 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_kb is rejected with 422.
  • Mutually exclusive with quality. Send one or the other; sending both returns 400.
  • Tier-capped. target_size_kb larger than your tier's output cap returns 413 before any encoding work, so a typo doesn't burn CPU.
  • Below-floor edge case. If even quality 1 exceeds the target (the input simply can't be that small without resizing), the server returns the smallest possible output anyway — status 200, and X-FileMorph-Achieved-Bytes reveals 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, achieved

JavaScript (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.zip

Batch Conversion — The Three Response Shapes

POST /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.

Multipart layout

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.zip

files 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.

The three response shapes

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).

Python — handle all three shapes

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}")

JavaScript — browser upload

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}`);
}

Manifest schema

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/batch the 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 /convert returns the same message as a 400 with X-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.

Duplicate filenames

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 Quotas & Discovery

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.

Discover your tier

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.

What happens at each cap

  • File size exceeded → 413 Request Entity Too Large with "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 Request with "Batch size N exceeds tier limit of M."
  • Output cap exceeded → 413 with "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 Requests with a Retry-After header (seconds).
  • Monthly API calls used up → 429 with a Retry-After header counting down to 00:00 UTC on the 1st of next month.
  • Rate limit exceeded → 429 without a Retry-After header — see backoff guidance below.
  • Too many rejected API keys from your IP address (more than 30 in a minute) → 429 with a Retry-After header and {"detail": "Too many invalid API key attempts. Try again later."}. The key is the problem: fix it — after the wait, a wrong key gets 401 again.

Why the output cap exists

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.


Error Handling — Standard Patterns

Error envelope

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 429 from 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 422 with the manifest shape, { "summary": …, "files": […] } (see above).

Status code matrix

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

Retry policy

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.

Magic-byte filter

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.


Format Discovery — GET /api/v1/formats

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.


Cross-Origin Notes (CORS)

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.

What to expect

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.

Reading the filename

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.

Self-hosted CORS setup

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.


Practical Patterns

Synchronous-only

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 with 500 (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.

Idempotency

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.

Concurrency

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.


Resources

Bug reports & feature requests: github.com/MrChengLen/FileMorph/issues