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.
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"{
"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.
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'<?php
$api = getenv('FC_API');
$token = getenv('FC_TOKEN');
$ch = curl_init($api . '/tasks/convert?output=json');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],
CURLOPT_POSTFIELDS => [
'file' => new CURLFile('photo.heic'),
'output_format' => 'jpg',
'quality' => '88',
],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$result = json_decode($body, true);
if ($status !== 200) {
// The error envelope is uniform: quote request_id when reporting it.
throw new RuntimeException($result['error']['message'] . ' (' . $result['request_id'] . ')');
}
echo $result['files'][0]['url'], "\n";import { createReadStream } from 'node:fs';
const api = process.env.FC_API;
const token = process.env.FC_TOKEN;
const form = new FormData();
form.set('file', await openAsBlob('photo.heic'), 'photo.heic');
form.set('output_format', 'jpg');
form.set('quality', '88');
const res = await fetch(`${api}/tasks/convert?output=json`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: form,
});
if (res.status === 429) {
// Honour the header. Do not retry faster than it says.
throw new Error(`rate limited, retry in ${res.headers.get('Retry-After')}s`);
}
const body = await res.json();
if (!res.ok) {
throw new Error(`${body.error.code}: ${body.error.message}`);
}
console.log(body.files[0].url);package main
// The bundled fileconvert CLI converts locally against the same storage;
// over HTTP the request is an ordinary multipart POST.
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
)
type result struct {
Files []struct {
Name string `json:"name"`
Size int64 `json:"size"`
URL string `json:"url"`
} `json:"files"`
Job string `json:"job"`
}
func main() {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
src, err := os.Open("photo.heic")
if err != nil {
panic(err)
}
defer src.Close()
part, _ := w.CreateFormFile("file", "photo.heic")
io.Copy(part, src)
w.WriteField("output_format", "jpg")
w.WriteField("quality", "88")
w.Close()
url := os.Getenv("FC_API") + "/tasks/convert?output=json"
req, _ := http.NewRequest("POST", url, &buf)
req.Header.Set("Authorization", "Bearer "+os.Getenv("FC_TOKEN"))
req.Header.Set("Content-Type", w.FormDataContentType())
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var out result
json.NewDecoder(res.Body).Decode(&out)
fmt.Println(out.Files[0].URL)
}
The fileconvert Go binary that ships in cli/ is not an
HTTP client — it drives the same on-disk job store directly, which is why
fileconvert convert in.png out.jpg works with no server running.
Over the network, use net/http as above.
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
Feature detection public
# 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.
-
Authenticate
One bearer token, minted with the scopes it needs and nothing more.
task.readlists and reads jobs,task.writecreates and deletes them,user.readreads key metadata./health,/formats,/toolsand/openapi.jsonare 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"] } -
Create a job
Each task declares an
operationand the tasks it consumes viainput. Every job needs at least one terminalexport/urltask — without one the work would run and the results would be unreachable. Drop?wait=1and the call returns201with aLocationheader 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"]} } }' -
Download the result
The finished record carries a flat
filesarray — one entry per file produced by an export task, each with aname,size,tokenandurl. Links stop working at the job'sexpires_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
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.
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
{
"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.
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.
RateLimit-Limit: 600
RateLimit-Remaining: 594
RateLimit-Reset: 1785638460
Retry-After: 12Questions developers actually ask
You need an API key, which means an account. Converting files in the browser does not. Keys are minted from your account page, or from the command line with php cli.php keys:create --scopes=task.read,task.write --label='ci pipeline'. The token is displayed once and only its SHA-256 hash is kept, so store it when you see it.
A one-shot POST /tasks/convert builds the whole job for you — an import/upload, your operation, and an export/url — and runs it inline. POST /jobs is the general form: you describe the graph yourself, chain as many operations as you like, and choose whether to wait. Use the one-shot for a single file and a single operation; use jobs for anything with more than one step.
Three ways, in increasing order of kindness to your infrastructure. Poll GET /jobs/{id}. Block on GET /jobs/{id}/wait, which is convenient but holds a PHP-FPM worker for the duration. Or register a webhook and get a signed POST on job.finished — the right answer for anything slow.
No, and it is the single most common client mistake. The status code describes the API request, not the conversion. A job that was accepted, validated and run, and whose tasks then failed, is a successful request reporting a failed job. Always branch on the status field, not on the HTTP code alone. Files from tasks that did succeed stay available until the job expires, which makes debugging much easier.
Until the job's expires_at, which defaults to FC_FILE_TTL (24 hours) and can be overridden per job with a ttl field in seconds. Retention is enforced by cli.php purge, so on a self-hosted instance make sure that runs — nothing else deletes job directories.
Deliberately not. Registration is server-side only, so a leaked API key cannot redirect your results to somebody else's endpoint. The URL also passes the same SSRF guard as URL imports: no private, loopback, link-local or metadata addresses unless you register the hook with an explicit allow_private or allow_hosts exemption.
Billable processing seconds, rounded up to the minute. Imports and exports are free; conversion, optimisation, thumbnailing, watermarking, merging, archiving, website capture and metadata are counted. On a self-hosted instance nothing is charged — it is an accounting hook for a service that wants one.
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.