REST API
The REST API is the full surface of flickies. Every route is under /v1/ on port 8000, takes and returns JSON (except file transfers), and is described in openapi.yaml. The same operations are available as MCP tools.
The server does not validate request bodies against openapi.yaml. Each route reads the fields it needs; a missing required field such as start_sec on trim or rows on thumbnail grid ends in a plain-text 500 Internal Server Error instead of a JSON error. Send Content-Type: application/json on every POST: without it the request also fails with that plain-text 500.
The contract
Every video endpoint follows the same rules.
Input: exactly one of file_path (relative to the staging area, /data/files) or file_url (an http or https URL the server downloads to a temp file). Both or neither is 400 must provide exactly one of: file_path, file_url. Lipsync uses face_path/face_url and audio_path/audio_url with the same rule; concat and mux audio have their own fields.
Output: exactly one of output_path (written into the staging area, parent directories created) or output_url (the server PUTs the result there, for example a presigned S3 URL). Both is 400. Neither is 400 unless async_job is true.
| Output | Response |
|---|---|
output_path | 200 {"path": "<output_path>", "size": <bytes>} |
output_url | 200 {"url": "<output_url>", "size": <bytes>} |
async_job: true | 202 {"job_id": "<uuid>", "status": "accepted"} |
An existing file at output_path is overwritten.
Async jobs: with "async_job": true the work runs in the background and the response is 202 at once. The result always goes to jobs/<uuid>.<ext> in the staging area; output_path and output_url are ignored in async mode (sending both is still rejected). Poll GET /v1/jobs/{job_id} or pass webhook_url to get called back (Webhooks). Lipsync and restore load their engine before queueing the job, so a cold engine still holds the request while it downloads and loads.
Paths are checked before any disk access: empty, absolute, containing .., containing a NUL byte, or resolving outside the staging area is 400.
Health and discovery
GET /healthz
Liveness probe. Always {"status": "ok"}, never needs a token, not rate limited.
GET /v1/health
{
"status": "ok",
"version": "...",
"device": "auto",
"ffmpeg": "ffmpeg version ...",
"available_engines": ["gfpgan", "latentsync-1.5", "wav2lip", "wav2lip-gan"],
"enabled_engines": [],
"loaded_engine": null,
"noncommercial_enabled": false
}
device is the FLICKIES_DEVICE setting, not the detected device. enabled_engines is FLICKIES_ENABLED_ENGINES. ffmpeg is the first line of ffmpeg -version, or null if ffmpeg failed. This route needs the bearer token when auth is on; only /healthz is exempt.
Engines
GET /v1/engines
Lists every registered engine:
{
"engines": [
{
"slug": "wav2lip",
"executor": "wav2lip",
"variant": "base",
"loaded": false,
"noncommercial": true,
"cuda_only": false,
"vram_gb_min": 3,
"description": "Wav2Lip base ...",
"last_used_secs_ago": null
}
]
}
DELETE /v1/engines/{slug}
Unloads the engine and frees its memory. 204 whether or not it was loaded, 404 ENGINE_NOT_REGISTERED for an unknown slug. After this, loaded_engine in /v1/health can still name the engine until another one loads; loaded in /v1/engines is the real state. Evicting an engine another caller is using breaks their request.
Files
The staging area is /data/files. There is no listing endpoint.
PUT /v1/files/{path}
Uploads the raw request body (not multipart) to {path}, creating directories, overwriting any existing file. 201:
{
"path": "uploads/clip.mp4",
"size": 1048576,
"sha256": "e3b0c442..."
}
A body over FLICKIES_MAX_UPLOAD_BYTES (5 GiB by default) is 413 PAYLOAD_TOO_LARGE, and the partial file is removed.
GET /v1/files/{path}
Streams the file as application/octet-stream. 404 NOT_FOUND if it does not exist.
DELETE /v1/files/{path}
204 on success, 404 if missing, 400 refuse to delete a directory for a directory.
Jobs
GET /v1/jobs/{job_id}
{
"job_id": "3f0c2a5e-...",
"status": "complete",
"result": {
"path": "jobs/9b1e7d40-....mp4",
"size": 2211840
},
"error": null
}
status is pending, running, complete or failed. A failed job has result: null and error: {"code": "JOB_FAILED", "message": "..."}. 404 for an unknown id.
Jobs live in the server’s memory: they are lost on restart and never expire while it runs. There is no cancel endpoint. The file name in result.path is a different UUID from job_id, so read the path from the result.
Lipsync
POST /v1/video/lipsync
Drives a face video or still image with an audio track.
| Field | Required | Default | Meaning |
|---|---|---|---|
face_path / face_url | one of | The face. A still image is recognised by a .jpg, .jpeg or .png name (Wav2Lip), so stage images and pass face_path. | |
audio_path / audio_url | one of | The driving audio, any format ffmpeg reads. | |
engine | no | latentsync-1.5 | latentsync-1.5, wav2lip or wav2lip-gan (Engines). |
output_path / output_url / async_job / webhook_url | see the contract |
Output is MP4. restore_face appears in openapi.yaml but the REST route ignores it; run restore on the result, or use the MCP lipsync tool, which honours it.
curl -s -X POST http://localhost:8000/v1/video/lipsync \
-H "Content-Type: application/json" \
-d '{
"face_path": "uploads/face.mp4",
"audio_path": "uploads/voice.wav",
"engine": "latentsync-1.5",
"output_path": "out/talking.mp4"
}'
Errors: 404 ENGINE_NOT_REGISTERED for an unknown engine, 403 NONCOMMERCIAL_GATE_REFUSED for Wav2Lip without the opt-in, 400 when LatentSync runs without CUDA.
Restore
POST /v1/video/restore
Runs GFPGAN v1.4 over every frame and puts the source audio back. Takes the standard input and output fields and returns MP4. The optional engine field in the spec only allows gfpgan, and the route always uses gfpgan.
curl -s -X POST http://localhost:8000/v1/video/restore \
-H "Content-Type: application/json" \
-d '{
"file_path": "out/talking.mp4",
"output_path": "out/talking-restored.mp4"
}'
ffmpeg operations
These run ffmpeg on the CPU in both images, never touch the engine pool, and never evict a loaded model. A non-zero ffmpeg exit is 500 FFMPEG_FAILED with the command and the last 2 KB of stderr in details.
POST /v1/video/trim
| Field | Required | Default | Meaning |
|---|---|---|---|
start_sec | yes | Start, in seconds. | |
end_sec | yes | End, in seconds. Must be greater than start_sec (400 end_sec must be > start_sec). | |
precise | no | false | false copies streams: fast, but the start snaps to a keyframe and can lose up to one GOP. true re-encodes with libx264 CRF 18 (preset veryfast) and AAC 192k for frame-accurate cuts. |
Output is MP4.
POST /v1/video/concat
| Field | Required | Default | Meaning |
|---|---|---|---|
inputs_paths / inputs_urls | one of | Two or more inputs, joined in order. Fewer than two is 400 concat needs >= 2 inputs. | |
precise | no | false | false stream-copies through the concat demuxer, which needs every input to share codec, timebase and sample aspect ratio. true re-encodes to libx264 CRF 18 and AAC 192k so mixed inputs join cleanly. |
Takes the standard output fields. Output is MP4.
POST /v1/video/transcode
| Field | Required | Default | Meaning |
|---|---|---|---|
output_format | no | mp4 | Container and file extension: mp4, webm, mov, mkv or gif. |
video_codec | no | ffmpeg’s choice | Passed as -c:v, for example libx264, libx265, libvpx-vp9, libaom-av1. |
audio_codec | no | ffmpeg’s choice | Passed as -c:a, for example aac, libopus, copy. |
crf | no | Passed as -crf. | |
preset | no | Passed as -preset. | |
fps | no | source rate (12 for GIF) | Output frame rate. |
gif_options | no | GIF only: width (scales with Lanczos, height follows the aspect) and loop (default 0, loop forever). palette_mode is accepted and ignored; GIFs always use one palette generated from the clip. |
curl -s -X POST http://localhost:8000/v1/video/transcode \
-H "Content-Type: application/json" \
-d '{
"file_path": "uploads/clip.mp4",
"output_format": "gif",
"fps": 10,
"gif_options": {
"width": 480
},
"output_path": "out/clip.gif"
}'
POST /v1/video/scale
| Field | Required | Default | Meaning |
|---|---|---|---|
width, height | yes | Output size in pixels. | |
keep_aspect | no | true | true scales to fit inside the box and pads the rest (letterbox), so the output is exactly width x height. false stretches. |
Audio is copied. Output is MP4.
POST /v1/video/mux_audio
| Field | Required | Default | Meaning |
|---|---|---|---|
video_path_or_url | yes | Staged path, or a URL starting with http:// or https://. | |
audio_path_or_url | yes | Same, for the audio. | |
replace_existing_audio | no | true | Has no effect: the output always takes the video from the first input and only the audio track of the second. |
The video stream is copied, the audio is encoded to AAC, and the output stops at the shorter input. Output is MP4. Takes the standard output fields.
POST /v1/video/extract_audio
| Field | Required | Default | Meaning |
|---|---|---|---|
audio_format | no | wav | wav (PCM 16-bit), mp3 (libmp3lame), m4a (AAC), ogg (Vorbis) or flac. Anything else is 400 unsupported audio format. |
Takes the standard input and output fields, including async_job.
POST /v1/video/thumbnail_grid
| Field | Required | Default | Meaning |
|---|---|---|---|
rows, cols | yes | Grid size. | |
cell_width, cell_height | no | 320, 180 | Size of each cell. Frames are scaled to it without keeping the aspect ratio. |
Produces one PNG. ffmpeg’s thumbnail filter picks one representative frame from each run of 100 frames, so the grid covers the first rows x cols x 100 frames of the video, not its whole length.
POST /v1/video/info
Takes file_path or file_url and returns ffprobe data for the first video and audio stream:
{
"duration_sec": 12.48,
"width": 1920,
"height": 1080,
"fps": 29.97,
"video_codec": "h264",
"audio_codec": "aac",
"bitrate": 4821337,
"container_format": "mov,mp4,m4a,3gp,3g2,mj2",
"size_bytes": 7521288
}
Webhooks
Set webhook_url together with async_job: true and the server POSTs the final job state there when the job ends:
{
"job_id": "3f0c2a5e-...",
"status": "complete",
"result": {
"path": "jobs/9b1e7d40-....mp4",
"size": 2211840
},
"error": null
}
Headers:
| Header | Value |
|---|---|
X-Webhook-Timestamp | Unix seconds when the delivery was signed. |
X-Webhook-Signature | t=<timestamp>,v1=<hex>, where <hex> is HMAC-SHA256 with FLICKIES_WEBHOOK_SECRET over <timestamp>.<raw body>. Empty v1= when no secret is set. |
To verify, recompute the HMAC over the timestamp, a dot and the exact body bytes, and compare in constant time:
import hashlib
import hmac
def verify(secret: bytes, timestamp: str, body: bytes, signature: str) -> bool:
expected = hmac.new(
secret,
timestamp.encode() + b"." + body,
hashlib.sha256,
).hexdigest()
received = signature.split("v1=", 1)[-1]
return hmac.compare_digest(expected, received)
A delivery counts as done on any 2xx. Otherwise the server retries after 30 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours (seven attempts, 30-second timeout each), then logs webhook dead-letter and gives up. Retries resend the same body, timestamp and signature, so de-duplicate on them. Pending retries are lost on restart.
Request headers
| Header | Effect |
|---|---|
Authorization: Bearer <token> | Required when FLICKIES_AUTH_TOKEN is set. |
X-Request-Id | Used as the request id if it is a UUID or a ULID; otherwise the server makes a UUID4. Echoed on every response and present on every log line. |
X-Trace-Id | Same validation; defaults to the request id. Echoed on every response. Both ids are forwarded on file_url downloads and output_url uploads. |
Idempotency-Key | On a POST, the first response below 500 for this key, method and path is cached in memory and replayed for later requests with the same key. The body is not compared. Two identical requests in flight at once both run. |
Errors
Errors are JSON:
{
"code": "BAD_REQUEST",
"message": "must provide exactly one of: file_path, file_url",
"details": {}
}
details appears only when there is something in it.
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | Input or output rules broken, bad path, bad URL or a private address, unsupported format, LatentSync without CUDA, engine weights not available. |
| 401 | UNAUTHORIZED | Token missing (missing Authorization: Bearer header) or wrong (invalid bearer token). |
| 403 | NONCOMMERCIAL_GATE_REFUSED | A Wav2Lip engine without FLICKIES_ENABLE_NONCOMMERCIAL. |
| 404 | NOT_FOUND | File, job or route not found. |
| 404 | ENGINE_NOT_REGISTERED | Engine slug not in the registry. |
| 413 | PAYLOAD_TOO_LARGE | Upload over FLICKIES_MAX_UPLOAD_BYTES. |
| 422 | VALIDATION_FAILED | The body is not valid JSON, or not a JSON object. details.errors lists the problems. |
| 429 | RATE_LIMITED | Over FLICKIES_RATE_LIMIT_PER_MIN. Comes with Retry-After: 60. |
| 500 | FFMPEG_FAILED | ffmpeg or ffprobe exited non-zero. details has cmd and stderr. |
| 500 | INTERNAL_SERVER_ERROR | Inference failed (wav2lip inference failed: ... and similar). |
| 502 | UPSTREAM_FETCH_FAILED | A file_url download or output_url upload got an HTTP error or a transport error. |
Inside a job, every failure becomes error.code JOB_FAILED, with the original error as text in error.message.