Lipsync
Sync lips in images or videos to audio tracks using the Kolbo API.
Sync lips in an image or a video to match an audio track. Provide a source image or video and an audio track, and Kolbo generates a video with matching lip movements.
Smart Select: Omit model (or send "auto" / "smart-select") and Kolbo picks a model from the family matching your source — see Two model families. Passing a specific identifier gives you deterministic behaviour, but you then have to match the family yourself.
Model identifiers are Kolbo-specific. Never hardcode model identifiers — always fetch the current list from GET /api/v1/models?type=lipsync first. Models may be added, renamed, or retired at any time.
All generation endpoints accept an optional
project_idbody field that routes the output into a specific project. See Projects.
Two model families
Lipsync models are catalogued under two distinct model types, and they accept different sources:
| Model type | Source it takes | Query |
|---|---|---|
lipsync-image | A still portrait — the model animates the face | GET /api/v1/models?type=lipsync-image |
lipsync-video | An existing video of a talking head — the model re-syncs the mouth | GET /api/v1/models?type=lipsync-video |
GET /api/v1/models?type=lipsync returns both families in one list; read each model's types array to see which one it belongs to.
The family must match your source, and this is enforced at request time:
- A
lipsync-imagemodel with no image source →400 MISSING_IMAGE. - A
lipsync-videomodel with no video source →400 MISSING_VIDEO. - A model that is neither family →
400 UNSUPPORTED_MODEL_TYPE.
Smart Select (omitting model) sidesteps this: it looks at whether you supplied a video or an image and then picks a routable model from the matching family, so it can never mismatch.
Audio length is gated per model, and the limit is not published on the models endpoint. Before charging credits the API measures your audio and rejects it with 400 DURATION_BELOW_MODEL_MINIMUM or 400 DURATION_EXCEEDS_MODEL_LIMIT. The applicable bound and your actual audio length are stated in the error message of that response, so a rejected request tells you exactly what to trim to. The min_audio_duration / max_audio_duration / min_video_duration / max_video_duration / audio_max_follows_video_duration / supported_audio_formats fields on GET /api/v1/models are catalog metadata and are not what this endpoint checks against.
Dubbing models cannot be driven from this endpoint. Some lipsync-video models are dubbing models, which require a target language. This endpoint does not accept one, so those models always return 400 MISSING_TARGET_LANGUAGE. Pick a non-dubbing lipsync model.
Endpoint
POST /api/v1/generate/lipsyncRate limit: 10 requests per minute per user, counted in a bucket shared with the other standard-limit /v1/generate/* endpoints. Image generation and transcription sit in their own, higher-limit buckets.
Request Body
Accepts multipart/form-data (for file uploads) or application/json (for URL-based inputs). The source slot and the audio slot are independent: you can pass the source as a URL and upload the audio, or the reverse. Within a single slot, a URL and a file are mutually exclusive — see the Callout below the table.
| Field | Type | Required | Description |
|---|---|---|---|
source_url | string | Conditional | URL of the source image or video. Required unless an image or video file is uploaded. The file extension decides the type: .mp4, .mov, .webm, .mkv, .avi, .m4v (optionally followed by a query string) = video, anything else = image. |
image | file | Conditional | Source image file — multipart field name image, 1 file, 100 MB max. |
video | file | Conditional | Source video file — multipart field name video, 1 file, 100 MB max. |
audio_url | string | Conditional | URL of the audio track. Required unless an audio file is uploaded. |
audio | file | Conditional | Audio file — multipart field name audio, 1 file, 100 MB max. |
prompt | string | No | Optional performance prompt for models that support it. For Sync-3 this is the free-text emotion/acting prompt (e.g. "speaking with excitement, calm and serious"). Must be a string when present (400 INVALID_PROMPT). |
model | string | No | Model identifier from GET /api/v1/models?type=lipsync-image or ?type=lipsync-video. Must be a string, never an array (400 INVALID_MODEL). A string that matches no catalogued model returns 400 GENERATION_ERROR with the message Model not found. Omitted / "auto" / "smart-select" → Smart Select. |
bounding_box_target | array of 2 numbers | No | Primary-face position as [x, y], normalized 0-1 (e.g. [0.5, 0.4] for a centred, slightly-high face). Used by Hedra Character-3 only. A malformed value is ignored rather than rejected. |
project_id | string | No | Target project. Omit to use your auto-created "API Generations" project. |
Do not fill the same slot twice. Sending source_url together with an image upload returns 400 CONFLICTING_IMAGE_INPUT; with a video upload, 400 CONFLICTING_VIDEO_INPUT; sending audio_url together with an audio upload returns 400 CONFLICTING_AUDIO_INPUT. Mixing across slots (source as URL, audio as file) is fine.
Multipart field names matter. There is no source field — upload the source as image or video depending on what it is, and the audio as audio.
source_url type detection is purely filename-based. A video served from a URL without one of the recognised extensions (a signed URL ending in an id, for example) is classified as an image — which means a lipsync-video model then rejects the request with 400 MISSING_VIDEO, and Smart Select picks an image model that will treat your video as a still. Upload it as a video file instead, or use a URL that keeps the extension.
This endpoint does not accept aspect_ratio, resolution, duration, or visual_dna_ids — the output follows the source.
Sync-3 advanced options
The Sync-3 model (fal-ai/sync-lipsync/v3, a lipsync-video model) accepts these additional optional fields. All except sync_mode are ignored by other lipsync models.
| Field | Type | Description |
|---|---|---|
sync_mode | string | How to reconcile an audio/video length mismatch: cut_off (default), loop, bounce, silence, remap. Honoured by the whole fal-ai/sync-lipsync family, not just v3. |
model_mode | string | Which region drives the sync: lips, face, head, lipsync, emotion, talking_head. |
emotion | string | Quick emotion shortcut: neutral, happy, sad, angry, disgusted, surprised. A free-text prompt overrides this and gives finer control. |
temperature | number | Expressiveness, 0 (subtle) to 1 (energetic). |
occlusion_detection_enabled | boolean | Handle objects (hands, mics) passing in front of the face. |
active_speaker_detection | object | Choose which speaker to sync in a multi-person video (see below). |
active_speaker_detection fields:
| Field | Type | Description |
|---|---|---|
auto_detect | boolean | Auto-detect and sync the active speaker. |
v3 | boolean | Use the Sync.so v3 detection engine. |
frame_number | integer | Frame index the coordinates refer to. |
coordinates | [x, y] | Integer pixel point on the speaker's face, in the source video resolution. |
bounding_boxes | array | Per-frame face boxes [x1, y1, x2, y2]. |
bounding_boxes_url | string | URL to a JSON file with per-frame boxes. |
face_image | string | Base64-encoded reference face image. |
For multi-person videos, the simplest path is active_speaker_detection: { auto_detect: true }. To pin a specific person, send their face as a pixel coordinates point plus the frame_number it was taken from.
Only sync_mode survives every execution path. model_mode, emotion, temperature, occlusion_detection_enabled and active_speaker_detection are attached to the request only when the generation runs on the fal execution path. Some sync-lipsync models are served by another provider first (with fal as the fallback), and that path forwards the source video, the audio and sync_mode only — the remaining options are dropped silently, not rejected. Treat them as best-effort refinements rather than guarantees.
curl -X POST https://api.kolbo.ai/api/v1/generate/lipsync \
-H "X-API-Key: kolbo_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source_url": "https://example.com/two-people.mp4",
"audio_url": "https://example.com/speech.mp3",
"model": "fal-ai/sync-lipsync/v3",
"emotion": "happy",
"active_speaker_detection": { "coordinates": [640, 360], "frame_number": 30 }
}'Examples
cURL with URLs (Smart Select)
curl -X POST https://api.kolbo.ai/api/v1/generate/lipsync \
-H "X-API-Key: kolbo_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source_url": "https://example.com/portrait.jpg",
"audio_url": "https://example.com/speech.mp3"
}'cURL with File Uploads
curl -X POST https://api.kolbo.ai/api/v1/generate/lipsync \
-H "X-API-Key: kolbo_live_YOUR_API_KEY" \
-F "[email protected]" \
-F "[email protected]"cURL with Video Source
curl -X POST https://api.kolbo.ai/api/v1/generate/lipsync \
-H "X-API-Key: kolbo_live_YOUR_API_KEY" \
-F "[email protected]" \
-F "[email protected]"cURL with a mixed source (uploaded audio, source URL)
curl -X POST https://api.kolbo.ai/api/v1/generate/lipsync \
-H "X-API-Key: kolbo_live_YOUR_API_KEY" \
-F "source_url=https://example.com/portrait.jpg" \
-F "[email protected]"With Specific Model
To choose a specific model, first fetch identifiers from the family matching your source (?type=lipsync-image or ?type=lipsync-video), then pass the identifier value:
curl -X POST https://api.kolbo.ai/api/v1/generate/lipsync \
-H "X-API-Key: kolbo_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source_url": "https://example.com/portrait.jpg",
"audio_url": "https://example.com/speech.mp3",
"model": "your-model-identifier"
}'Model identifiers come from GET /api/v1/models?type=lipsync. Always fetch the latest list rather than hardcoding identifiers, as models may change over time.
JavaScript
const API_KEY = "kolbo_live_YOUR_API_KEY";
// Fetch available lipsync models
async function initModels() {
const res = await fetch("https://api.kolbo.ai/api/v1/models?type=lipsync", {
headers: { "X-API-Key": API_KEY },
});
const data = await res.json();
console.log("Available models:", data.models.map((m) => m.identifier));
}
async function main() {
await initModels();
// Using URLs (Smart Select)
const response = await fetch("https://api.kolbo.ai/api/v1/generate/lipsync", {
method: "POST",
headers: {
"X-API-Key": API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
source_url: "https://example.com/portrait.jpg",
audio_url: "https://example.com/speech.mp3",
}),
});
const started = await response.json();
if (!started.success) throw new Error(started.error);
const result = await pollUntilDone(started.generation_id, started.poll_interval_hint);
if (result.state !== "completed") throw new Error(result.error || result.state);
console.log("Video URL:", result.result.urls[0]); // urls is always an array
}
const TERMINAL = new Set(["completed", "failed", "cancelled"]);
// Minimal loop. A production client should also handle transient HTTP errors and
// an overall timeout — see /docs/developer-api/polling-and-cancellation.
async function pollUntilDone(generationId, intervalSeconds) {
while (true) {
await new Promise((r) => setTimeout(r, intervalSeconds * 1000));
const status = await fetch(
`https://api.kolbo.ai/api/v1/generate/${generationId}/status`,
{ headers: { "X-API-Key": API_KEY } }
).then((r) => r.json());
if (TERMINAL.has(status.state)) return status;
}
}
main();Python
import requests
import time
API_KEY = "kolbo_live_YOUR_API_KEY"
BASE_URL = "https://api.kolbo.ai/api"
HEADERS = {"X-API-Key": API_KEY}
# Fetch available lipsync models
models_res = requests.get(
f"{BASE_URL}/v1/models",
headers=HEADERS,
params={"type": "lipsync"},
)
print("Available models:", [m["identifier"] for m in models_res.json()["models"]])
# --- Option A: Using URLs (Smart Select) ---
response = requests.post(
f"{BASE_URL}/v1/generate/lipsync",
headers={**HEADERS, "Content-Type": "application/json"},
json={
"source_url": "https://example.com/portrait.jpg",
"audio_url": "https://example.com/speech.mp3",
},
)
data = response.json()
print("Generation ID:", data["generation_id"])
# --- Option B: Using file uploads ---
# with open("portrait.jpg", "rb") as img, open("speech.mp3", "rb") as aud:
# response = requests.post(
# f"{BASE_URL}/v1/generate/lipsync",
# headers=HEADERS,
# files={"image": img, "audio": aud},
# )
# data = response.json()
TERMINAL = {"completed", "failed", "cancelled"}
# Minimal loop. Add transient-error retries and an overall timeout for production
# — see /docs/developer-api/polling-and-cancellation.
while True:
time.sleep(data["poll_interval_hint"])
status = requests.get(
f"{BASE_URL}/v1/generate/{data['generation_id']}/status",
headers=HEADERS,
).json()
if status["state"] in TERMINAL:
break
if status["state"] != "completed":
raise Exception(status.get("error", status["state"]))
print("Video URL:", status["result"]["urls"][0]) # urls is always a listResponse
This endpoint is asynchronous and fire-and-forget: the POST answers 202 Accepted as soon as the job is queued and never contains a video.
Polling is the only completion mechanism. Kolbo never calls you back: there is no webhook, no callback_url field on this request body, and no route delivers outbound notifications. Socket.IO events are the web app's internal transport and are not part of the API contract — API-key generations are registered so the shared progress emitter drops their events, and the few emit sites that bypass that check are undocumented, unversioned and unsafe to build on. After the POST, loop against GET /api/v1/generate/{generation_id}/status until state is completed, failed or cancelled. Full contract: Polling & Cancellation.
Generation Started
{
"success": true,
"generation_id": "6820f1c2a1b2c3d4e5f60718",
"type": "lipsync",
"model": "auto",
"credits_charged": null,
"poll_url": "/v1/generate/6820f1c2a1b2c3d4e5f60718/status",
"poll_interval_hint": 8,
"session_id": "6820f1c2a1b2c3d4e5f60700",
"project_id": "6820f1c2a1b2c3d4e5f606ff"
}| Field | Type | Notes |
|---|---|---|
generation_id | string | Mongo ObjectId. This is what you poll and cancel with. |
type | string | Always "lipsync" |
model | string | Echoes "auto" for Smart Select requests, otherwise the identifier you sent |
credits_charged | null | Always null on this endpoint — the lipsync pipeline reports no estimate at submit time. Read credits_used from the completed status. |
poll_url | string | The status path without the /api prefix. Prepend https://api.kolbo.ai/api. |
poll_interval_hint | number | Suggested seconds between polls — 8 here |
session_id / project_id | string | Where the generation lives in the Kolbo app |
Completed Status
GET /api/v1/generate/{generation_id}/status
{
"success": true,
"generation_id": "6820f1c2a1b2c3d4e5f60718",
"type": "lipsync",
"state": "completed",
"progress": 100,
"result": {
"urls": ["https://media.kolbo.ai/videos/.../lipsync.mp4"],
"thumbnail_url": "https://media.kolbo.ai/videos/.../thumb.jpg",
"duration": null,
"aspect_ratio": "16:9",
"model": null,
"created_at": "2026-04-12T10:00:00.000Z"
},
"credits_used": 40,
"credits_breakdown": [
{ "model": "MODEL_IDENTIFIER", "amount": 40, "base": 40, "final": 40, "duration_multiplier": 1, "pricing": null }
]
}The output lives in result.urls — always an array of strings, even though lipsync produces exactly one video. Read urls[0].
result (and credits_used / credits_breakdown) appear only under state: "completed"; a processing body has exactly five keys and no result at all.
Lipsync results are sparser than the other video endpoints. The lipsync record does not persist the fields the shared status shaper reads, so on this endpoint specifically:
result.durationis alwaysnull— measure the output yourself, or use the length of the audio you supplied.result.modelis alwaysnull— the identifier that ran is reported incredits_breakdown[].modelinstead, or keep the one you sent.result.prompt_usedis omitted entirely (lipsync has no prompt field).- No creative-input keys (
visual_dna,preset,cinematic_presets,moodboard) are ever returned.
urls, thumbnail_url, aspect_ratio and created_at are populated as normal.
Failure and cancellation
A failed generation is still an HTTP 200 with success: true — the failure is in state, with an error string and a best-effort failure object. cancelled carries neither result nor error, so treat all three terminal states explicitly. Failed generations are not charged. See Polling & Cancellation.
Errors
| Status | Code | Cause |
|---|---|---|
| 400 | — | Neither source_url nor an image/video upload was supplied |
| 400 | — | Neither audio_url nor an audio upload was supplied |
| 400 | INVALID_PROMPT | prompt sent as a non-string |
| 400 | INVALID_MODEL | model sent as an array or object instead of a string |
| 400 | GENERATION_ERROR | model is a string but no model with that identifier exists — the response error is Model not found |
| 400 | MISSING_IMAGE | A lipsync-image model was chosen but the source is not an image |
| 400 | MISSING_VIDEO | A lipsync-video model was chosen but the source is not a video |
| 400 | UNSUPPORTED_MODEL_TYPE | model is not a lipsync-image or lipsync-video model |
| 400 | CONFLICTING_IMAGE_INPUT | source_url (image) sent together with an image upload |
| 400 | CONFLICTING_VIDEO_INPUT | source_url (video) sent together with a video upload |
| 400 | CONFLICTING_AUDIO_INPUT | audio_url sent together with an audio upload |
| 400 | DURATION_BELOW_MODEL_MINIMUM | Audio shorter than the model's minimum — the bound is stated in the error message |
| 400 | DURATION_EXCEEDS_MODEL_LIMIT | Audio longer than the model's maximum — the bound is stated in the error message |
| 400 | MISSING_TARGET_LANGUAGE | A dubbing model was chosen; this endpoint cannot supply a target language |
| 400 | URL_DOWNLOAD_FAILED | A supplied source_url / audio_url could not be fetched |
| 403 | INSUFFICIENT_CREDITS | Balance too low — checked before the generation starts |
| 429 | — | More than 10 generation requests in one minute |
Tips
- Lipsync generation typically takes 1-5 minutes depending on the source length.
- The audio-duration check runs before credits are deducted, so a too-long or too-short track costs you nothing — retry with the bound reported in the error message.
- For best results with
lipsync-imagemodels, use a clear, front-facing portrait with visible lips. - Uploads are limited to 100 MB per file.
- Use
bounding_box_targeton Hedra Character-3 to point at the face that should be driven; other models ignore it. - Credit cost depends on the model and the output length — see Models and Pricing.
- Use
poll_interval_hintfrom the initial response to set your polling interval.
Related
Speech and Sound
Generate the voice track you feed into lipsync
Video to Video
Restyle or re-edit an existing video
Elements Generation
Animate reference images, videos, and audio together
Models and Pricing
Model catalog, duration constraints, and credit multipliers
Polling and Cancellation
The state machine, the reference poll loop, failures and cancel