Developer API

Conversion, as an endpoint you can curl.

Post a file, name an output format, get a download link back. When one call is not enough, describe the whole pipeline as a graph of named tasks and let the server work out the order. JSON in, JSON out, a bearer token on every route that touches data.

Scoped bearer keys OpenAPI at /openapi.json Self-hostable

POST /tasks/convertmultipart/form-data
export FC_API=https://convert.example.com/api.php
export FC_TOKEN=fc_live_…

curl -sS -X POST "$FC_API/tasks/convert?output=json" \
  -H "Authorization: Bearer $FC_TOKEN" \
  -F "file=@quarterly-report.docx" \
  -F "output_format=pdf"
200 OKapplication/json
{
  "task": {
    "name": "task-1",
    "operation": "convert",
    "status": "finished",
    "duration": 2.41
  },
  "files": [
    {
      "task": "export-1",
      "name": "quarterly-report.pdf",
      "size": 184320,
      "token": "aG9sZG9uCg",
      "url": "/api.php/files/aG9sZG9uCg"
    }
  ],
  "job": "9f2c1e0b4a7d6c5e8f0a1b2c3d4e5f60"
}

One-shot conversion POST /tasks/convert

The same conversion, four ways

Upload a HEIC, ask for JPEG at quality 88, print the download URL. Add ?output=json for the envelope shown here; leave it off and the response body is the converted file itself. No client library anywhere in sight.

convert.shshell
curl -sS -X POST "$FC_API/tasks/convert?output=json" \
  -H "Authorization: Bearer $FC_TOKEN" \
  -F "file=@photo.heic" \
  -F "output_format=jpg" \
  -F "quality=88" \
  | jq -r '.files[0].url'

What the API actually gives you

Eight things worth knowing before you write the first request. Every one of them is a behaviour of the running service, not a roadmap item.

  • Synchronous when you want it, queued when you do not POST /jobs returns 201 straight away and runs the work after the response. Add ?wait=1 — or a Prefer: wait header — and the same call blocks and hands back the finished record instead.
  • Jobs are task graphs, validated before anything runs Name your tasks, point each one at its input, and the server topologically sorts them. Missing inputs, cycles, unknown operations and jobs with no export are rejected with a 422 that names the offending task.
  • Signed webhooks instead of polling job.created, job.finished and job.failed are delivered as HMAC-SHA256 signed POSTs, retried with backoff on 429 and 5xx, and at-least-once — so key your handler on data.id.
  • The format matrix is queryable, not a marketing number GET /formats returns every route this build knows about, and GET /tools tells you which binaries are actually installed on the host answering you. Feature-detect instead of guessing.
  • Four ways in, download links out Import from a multipart upload, a URL behind an SSRF guard, base64 or raw bytes. Export mints per-file download tokens that need no bearer key, plus HMAC-signed URLs with a TTL you choose.
  • Limits you can read off the response Every response carries RateLimit-Limit, -Remaining and -Reset; a 429 carries Retry-After. Defaults are 600 requests a minute per key and 300 per client IP, both configurable.
  • Run it yourself, on your own storage The API is a single PHP front controller over a filesystem job store. Bring it up with the bundled compose file, point FC_STORAGE at a volume, and no file ever leaves your infrastructure.
  • Scoped keys, hashed at rest task.read, task.write and user.read are granted per key. Only the SHA-256 hash is stored, and verification walks every record so response timing never leaks which keys exist.

Scopes per key

task.read task.write user.read

Feature detection public

GET /toolsapplication/json
# Public routes. Ask the host what it can do instead of assuming.
curl -sS "$FC_API/formats?from=heic" | jq -r '.formats[0].to | join(" ")'
curl -sS "$FC_API/tools"

{
  "tools": {"ffmpeg": true, "libreoffice": true, "ghostscript": false},
  "installed": 2,
  "total": 3,
  "missing": ["ghostscript"]
}

Three steps to your first conversion

Authenticate, create a job, download what it produced. The two environment variables below are used throughout this page.

  1. Authenticate

    One bearer token, minted with the scopes it needs and nothing more. task.read lists and reads jobs, task.write creates and deletes them, user.read reads key metadata. /health, /formats, /tools and /openapi.json are public and need no token at all.

    GET /meapplication/json
    # Every route that touches data takes a bearer token.
    curl -sS "$FC_API/me" -H "Authorization: Bearer $FC_TOKEN"
    
    {
      "key": {
        "id": "k_7d1c…",
        "label": "ci pipeline",
        "hint": "fc_live_…9f2c",
        "scopes": ["task.read", "task.write"],
        "last_used_at": "2026-08-02T09:14:07Z"
      },
      "scopes": ["task.read", "task.write", "user.read"]
    }
  2. Create a job

    Each task declares an operation and the tasks it consumes via input. Every job needs at least one terminal export/url task — without one the work would run and the results would be unreachable. Drop ?wait=1 and the call returns 201 with a Location header immediately, running the job after the response.

    POST /jobsapplication/json
    # Four named tasks. The server sorts them; you never state the order.
    curl -sS -X POST "$FC_API/jobs?wait=1" \
      -H "Authorization: Bearer $FC_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "tag": "invoices/2026-08",
        "ttl": 3600,
        "tasks": {
          "fetch": {"operation": "import/url", "url": "https://example.com/invoice-1042.docx"},
          "pdf":   {"operation": "convert", "input": "fetch", "output_format": "pdf"},
          "thumb": {"operation": "thumbnail", "input": "pdf", "width": 400},
          "out":   {"operation": "export/url", "input": ["pdf", "thumb"]}
        }
      }'
  3. Download the result

    The finished record carries a flat files array — one entry per file produced by an export task, each with a name, size, token and url. Links stop working at the job's expires_at, 24 hours by default.

    GET /jobs/{id}shell
    # Every export task yields a file entry with a ready-to-use url.
    curl -sS "$FC_API/jobs/$JOB_ID" -H "Authorization: Bearer $FC_TOKEN" \
      | jq -r '.files[].url' \
      | xargs -n1 -I{} curl -sSOJ {}
    
    # /files/{token} needs no bearer key — the token is the capability,
    # so the link can be handed straight to a browser.

Endpoint reference

The complete route table. A dash in the scope column means the endpoint is public.

19 routes base — /api.php

Method Path Scope Purpose
GET / Service index: version, and the endpoint list.
GET /health Liveness. Returns status, version, php and a modules map.
GET /formats Every format and conversion route this build knows.
GET /tools Which converter binaries are present on this host.
GET /openapi.json The machine-readable specification.
POST /jobs task.write Create a job. Runs it after the response, or inline with ?wait=1.
GET /jobs task.read List jobs, newest first. Filter by status and tag; limit and offset.
GET /jobs/{id} task.read One job record, with per-task state and any produced files.
GET /jobs/{id}/wait task.read Block until the job leaves processing. Returns the record either way.
DELETE /jobs/{id} task.write Delete a job and every file it produced.
POST /tasks/{type} task.write One-shot: wraps a single operation in an import + task + export job.
POST /import/upload task.write Upload a file once and get a handle to reuse across tasks.
GET /files/{token} Download a produced file. The token is the capability; no bearer key.
POST /signed task.write Mint a signed, expiring URL for a job's output.
GET /signed/{payload}/{sig} Redeem one. An altered link is a 403; an expired one is a 410.
GET /me user.read The key behind this request: label, hint, scopes, last use.
POST /keys bootstrap Mint a key. Unauthenticated only from loopback while no key exists.
GET /keys user.read List keys. Secrets are never returned again.
DELETE /keys/{id} user.read + task.write Revoke a key.

Paths are relative to the front controller — https://convert.example.com/api.php in these examples. POST /keys is a bootstrap route: it works without authentication only from loopback while no key exists yet, so a fresh deployment can mint its first key. Prefer cli.php keys:create, which never exposes the route at all.

Webhooks

Register a URL once and stop polling. Deliveries are signed, retried and at-least-once — key your handler on data.id. Registering with no event list subscribes to all three.

  • job.created A job has been accepted and validated.
  • job.finished Every task succeeded.
  • job.failed A task errored, so the job did.

There is no HTTP route for registering a hook, by design: registration is server-side only, so a leaked API key cannot redirect your results somewhere else. The URL passes the same SSRF guard as URL imports.

Delivery at your endpoint

Headershttp
POST /hooks/file-convert HTTP/1.1
Content-Type: application/json
X-FileConvert-Event: job.finished
X-FileConvert-Timestamp: 1785638400
X-FileConvert-Signature: t=1785638400,v1=6f3c…

Verifying a delivery

The signature header is t=<unix>,v1=<hex>, where the HMAC-SHA256 is taken over "<timestamp>.<raw body>" with the hook's secret. Three rules, all of which matter: sign the raw bytes exactly as received, compare in constant time, and reject a timestamp outside the tolerance window — 300 seconds by default.

verify.jsnode
const crypto = require('crypto');

function verify(rawBody, header, secret, tolerance = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.trim().split('='))
  );
  if (!parts.t || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > tolerance) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Answer 2xx quickly and do the real work asynchronously; the sender's timeout is 10 seconds and a slow receiver becomes a retry storm. A 429 or 5xx is retried with exponential backoff up to three attempts — any other 4xx is treated as final, because the receiver has said the request is wrong.

Request body job.finished

Payloadapplication/json
{
  "event": "job.finished",
  "created_at": "2026-08-02T02:32:02Z",
  "data": {
    "id": "9f2c1e0b4a7d6c5e8f0a1b2c3d4e5f60",
    "status": "finished",
    "tag": "invoices/2026-08",
    "duration": 4.21,
    "credits": 3,
    "expires_at": "2026-08-03T02:32:02Z",
    "files": [
      { "name": "invoice-1042.pdf", "size": 184320, "url": "https://…/files/…" }
    ]
  }
}

Errors and rate limits

Success is the resource itself. Failure is always the same envelope, with a request_id that also appears in the server log line for that request.

A validation failureapplication/json
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
X-Request-Id: b6f0…

{
  "error": {
    "code": "invalid_task",
    "message": "task \"pdf\" names an unknown input: \"fetc\"",
    "details": ["fetch", "pdf"]
  },
  "request_id": "b6f0…"
}

Status codes 11 of them

Status When
400 Malformed request or unparseable JSON.
401 Missing or invalid bearer token.
403 The key is valid but lacks a required scope, or a signed URL was altered.
404 No such route, job, file token or task type. Unknown task types list the valid ones.
405 Wrong method. The Allow header names the right ones.
410 A signed URL has expired. Mint a new one; retrying will not help.
413 Body or upload over the configured limit.
422 Well-formed but invalid — the usual answer to a bad job definition.
429 Rate limited. Honour Retry-After; do not retry sooner.
500 A conversion or storage failure. The message carries the tool's own stderr.
503 A module or binary this route needs is not installed. Retrying will not help.

A job whose tasks failed still returns 200. The status code describes the request; the status field describes the work. Check both.

Rate limits per minute

  • 600 per API key Counted on the host, set by FC_RATE_LIMIT_*.
  • 300 per client IP Applies before the key is even read.
  • Per host, not per cluster N web containers give N times the limit unless they share the storage volume — which the bundled compose file does.
Response headershttp
RateLimit-Limit: 600
RateLimit-Remaining: 594
RateLimit-Reset: 1785638460
Retry-After: 12

Questions developers actually ask

Mint a key and convert something.

Keys are free to create and scoped to what you grant them. If you would rather keep every byte in-house, the whole service — API, worker, queue and converters — runs from the bundled compose file on your own hardware.