Start here
What you get
This API puts LumarLabs’s face and video models behind an HTTP call. You post an image, a clip or a live stream; you get back the same thing with a different face on it. Seven features, one key, nothing to install.
Two shapes of call. Most features are asynchronous: you submit a job, poll it, and collect a signed URL when it finishes. Full Live Swap is a session — you open it, stream through it, and close it, charged by the second you actually use.
Every call is authenticated with a bearer key and paid for from a balance you top up in advance. Nothing recurring, no floor to clear, and a failed job costs nothing.
Start here
Your first call
Three steps to your first render: upload an asset, submit a job, poll for the result.
# 1 · Upload an asset → get a file_key
FILE_KEY=$(curl -s https://api.lumarlabs.ai/api/v1/uploads \
-H "Authorization: Bearer $LUMARLABS_API_KEY" \
-F "file=@base.png" | jq -r .file_key)
# 2 · Submit the job
JOB=$(curl -s https://api.lumarlabs.ai/api/v1/face-swap-image \
-H "Authorization: Bearer $LUMARLABS_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"image_key\":\"$FILE_KEY\",\"face_key\":\"$FACE_KEY\"}")
# 3 · Poll the status_url the submit handed back
curl -s "https://api.lumarlabs.ai$(echo "$JOB" | jq -r .status_url)" \
-H "Authorization: Bearer $LUMARLABS_API_KEY"Start here
Using your key
Send your key as a bearer token on every request.
Authorization: Bearer lmr_live_xxxxxxxxxxxxxxxxxxxxxxxxKeep your key secret
Start here
Endpoints & versions
https://api.lumarlabs.ai/api/v1How it works
Sending files
Capability calls reference inputs by file_key, not by URL. Upload each asset first and pass back the key you get. We host the upload — there is no CORS or presign dance, and no external URLs are fetched.
/v1/uploadsmultipart/form-data| Field | Type | Required | Description |
|---|---|---|---|
file | file | Yes | Image, audio or video. Type is detected from the content type. |
{
"file_key": "api/images/9f8c…/base.png",
"url": "https://…"
}Images and audio up to 45 MB, video up to 500 MB.
How it works
Getting results back
Every capability except Full Live Swap is asynchronous: a submit returns immediately with an id, and you poll its status URL until it reaches a terminal state. Every submit answers with the same three fields, whichever capability it was:
{
"id": "…",
"status": "queued",
"status_url": "/api/v1/jobs/…"
}Poll status_url rather than assembling the path yourself — it is the one thing that stays correct if the route ever moves.
/v1/jobs/{job_id}{
"id": "…",
"status": "queued | processing | succeeded | failed",
"output_url": "https://…",
"charged_usd": 0.06,
"error": { "code": "face_not_detected", "message": "…" }
}Output URLs expire
You are billed when a job completes. A job that fails is refunded in full — you are never charged for a result you did not get.
How it works
Webhooks
Rather than polling, pass a callback_urlwhen you submit and we'll POST you the result the moment the job reaches a terminal state. Available on every capability that returns a job.
-d '{"image_key":"…","face_key":"…","callback_url":"https://yourapp.com/hooks/lumarlabs"}'The body carries the same object GET /v1/jobs/{id} returns, under data — so the handler you already wrote for polling can take this straight off the wire.
{
"event": "job.succeeded",
"sent_at": "2026-08-15T09:31:07Z",
"delivery_id": "…",
"data": {
"id": "…",
"status": "succeeded",
"output_url": "https://…",
"charged_usd": 0.06,
"error": null
}
}event is job.succeeded or job.failed. Failures are delivered too — a render that did not work is exactly the thing your user is waiting on.
Verify every delivery
An unverified endpoint is an open door
Each POST carries LumarLabs-Signature as t=<unix>,v1=<hex>. The hex is an HMAC-SHA256, keyed on your signing secret, over the string <t>.<raw body>. Sign the raw bytes — parsing and re-serialising the JSON first will not match.
import crypto from "node:crypto";
// express.raw() — NOT express.json(). The signature covers the bytes we sent.
app.post("/hooks/lumarlabs", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("LumarLabs-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", process.env.LUMARLABS_WEBHOOK_SECRET)
.update(parts.t + "." + req.body)
.digest("hex");
// Constant-time: a plain === leaks the answer one byte at a time.
const ok =
parts.v1 &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
if (!ok) return res.sendStatus(400);
// Reject anything older than five minutes so a captured delivery
// cannot be replayed at you later.
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return res.sendStatus(400);
const { event, data } = JSON.parse(req.body);
res.sendStatus(200); // acknowledge first, work afterwards
handleJob(event, data);
});import hashlib, hmac, time
@app.post("/hooks/lumarlabs")
def lumarlabs_hook():
header = request.headers.get("LumarLabs-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(
SECRET.encode(), f"{parts['t']}.".encode() + request.get_data(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
return "", 400
if abs(time.time() - int(parts["t"])) > 300:
return "", 400
payload = request.get_json()
return "", 200Your signing secret lives in the console under Billing. It is per-account, so it keeps working when you rotate an API key — rotate it separately if it is ever exposed.
Delivery, retries and duplicates
| Field | Type | Required | Description |
|---|---|---|---|
Success | 2xx | No | Any 2xx counts as delivered. Acknowledge first and do the work after — a slow handler is a timed-out delivery. |
Retries | 6 attempts | No | Anything else is retried with exponential backoff over roughly fifteen minutes, then abandoned. |
Timeout | 10s | No | We wait ten seconds for your response before treating it as a failure. |
delivery_id | string | No | Stable for a job across retries. Key on it if you want to be certain you act once. |
Webhooks do not replace the job endpoint
GET /v1/jobs/{id} remains the source of truth, and nothing about the result expires when a delivery fails.How it works
Safe retries
Send an Idempotency-Key header on any submit. A retry carrying the same key returns the original job — no second charge, no duplicate render. A network timeout costs you nothing.
-H "Idempotency-Key: your-own-unique-id"How it works
Throughput caps
60 requests per minute by default. Exceeding it returns 429 with code rate_limited.
rate_limited is not at_capacity
rate_limited means you are sending too fast — back off and retry. at_capacity means the platform is briefly saturated; retry shortly. They look alike and need different responses.How it works
When a call fails
Errors carry a stable code you can branch on and a message meant for a human. Branch on the code — the prose may change, the codes will not.
| HTTP | Code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing, unknown or revoked key. |
| 402 | insufficient_balance | Top up to continue. |
| 400 | invalid_input | A field failed validation. |
| 400 | unsupported_format | That file type isn't accepted. |
| 400 | input_too_large | Over the size or duration limit. |
| 400 | capability_unavailable | Currently switched off. |
| 404 | not_found | No such job, or not yours. |
| 429 | rate_limited | Slow down and retry. |
| 429 | at_capacity | Briefly saturated — retry shortly. |
| 503 | capacity_unavailable | No capacity for a live session right now. |
| 503 | capacity_warming | Voice capacity is starting up — retry in about a minute. |
| 500 | internal_error | Failed on our side. Nothing was billed; retry, and tell us if it persists. |
| — | face_not_detected | Job-level: no face in the input. |
| — | content_rejected | Job-level: failed a content check. |
| — | processing_failed | Job-level: the render didn't complete. |
Features
Character Swap
Put your character into a reference video, keeping its motion.
/v1/character-swap| Field | Type | Required | Description |
|---|---|---|---|
video_key | string | Yes | file_key of the reference video. |
character_key | string | Yes | file_key of the character image. |
resolution | string | No | 1k or 2k. Defaults to 1k. |
callback_url | string | No | We POST the terminal status here when the job finishes. See Webhooks. |
Features
Face Swap — video
Swap a face into a video.
/v1/face-swap| Field | Type | Required | Description |
|---|---|---|---|
video_key | string | Yes | file_key of the source video. |
face_key | string | Yes | file_key of the face to swap in. |
output_resolution_p | int | No | 480, 720 or 1080. |
callback_url | string | No | We POST the terminal status here when the job finishes. See Webhooks. |
Features
Face Swap — image
Swap a face into a single image.
/v1/face-swap-image| Field | Type | Required | Description |
|---|---|---|---|
image_key | string | Yes | file_key of the base image. |
face_key | string | Yes | file_key of the face to swap in. |
callback_url | string | No | We POST the terminal status here when the job finishes. See Webhooks. |
curl https://api.lumarlabs.ai/api/v1/face-swap-image \
-H "Authorization: Bearer $LUMARLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"image_key":"api/images/…","face_key":"api/images/…"}'Features
Motion Control
Animate a still character image to match a reference video’s motion.
/v1/motion-control| Field | Type | Required | Description |
|---|---|---|---|
image_key | string | Yes | file_key of the character image. |
motion_video_key | string | Yes | file_key of the motion reference. |
callback_url | string | No | We POST the terminal status here when the job finishes. See Webhooks. |
Features
Avatar
A talking avatar from one portrait and a script.
/v1/avatar| Field | Type | Required | Description |
|---|---|---|---|
image_key | string | Yes | file_key of the source portrait. |
script | string | Yes | What the avatar says. Up to 5,000 characters. |
voice_id | string | No | A specific voice. A default is chosen if omitted. |
language | string | No | Language hint for the voice. |
output_resolution_p | int | No | 480, 720 or 1080. |
callback_url | string | No | We POST the terminal status here when the job finishes. See Webhooks. |
Features
Lip Sync
Match a video’s mouth movement to your audio.
/v1/lip-sync| Field | Type | Required | Description |
|---|---|---|---|
video_key | string | Yes | file_key of the source video. |
audio_key | string | Yes | file_key of the audio to sync to. |
output_resolution_p | int | No | 480, 720 or 1080. |
callback_url | string | No | We POST the terminal status here when the job finishes. See Webhooks. |
Features
Full Live Swap
Real-time face swap over a live stream. Unlike the others this is a session, not a job: create one, connect over WebSocket, and stream.
/v1/full-live-swap/session| Field | Type | Required | Description |
|---|---|---|---|
duration_minutes | int | Yes | Minutes to fund. The stream hard-stops here. 1–240. |
prompt | string | No | Optional swap instruction. Up to 2,000 characters. A sensible default is used if omitted. |
{
"session_id": "…",
"stream_url": "wss://api.lumarlabs.ai/realtime",
"session_token": "rt_…",
"prompt": "…",
"max_duration_sec": 600,
"max_cost_usd": 25.00
}prompt comes back resolved, so you can see the default you were given when you did not send one.
{stream_url}?session_token={session_token}You pay for seconds streamed, not the block reserved
max_cost_usd is the ceiling — keep at least that much available to start. You are billed for the seconds you actually stream, and a session that never starts costs nothing.Close the socket to end it
max_duration_sec, so a dropped client cannot run up a bill beyond the block you funded.Features
Voice Changer
Real-time voice conversion. Like Full Live Swap this is a session rather than a job: open one, stream audio frames over the socket, and receive converted audio back on the same connection.
Pick a target voice first. The reference clip stays on our side — you never handle it.
/v1/voices[
{
"id": "8f2c…",
"name": "Narrator",
"description": "Warm, measured.",
"preview_url": "https://…/preview.mp3"
}
]/v1/voice/session| Field | Type | Required | Description |
|---|---|---|---|
duration_minutes | int | No | Minutes to fund. The session hard-stops here. 1–120, default 10. |
voice_profile_id | string | No | id from GET /v1/voices. Omit to pass audio through unchanged — useful for measuring latency before picking a voice. |
{
"session_id": "…",
"stream_url": "wss://api.lumarlabs.ai/realtime",
"session_token": "rt_…",
"max_duration_sec": 600,
"max_cost_usd": 3.60
}{stream_url}?session_token={session_token}On connect we hand the engine your chosen voice, then send you one JSON frame with the audio format to use in both directions:
{ "sample_rate": 22050, "chunk_frames": 4096 }After that it is audio both ways: send raw mono PCM (int16, little-endian, at the sample_rate above) as binary frames, and converted audio comes back in the same format. Send roughly chunk_frames per message — much smaller wastes round-trips, much larger adds latency.
You send audio, nothing else
Billed by the second, like Full Live Swap
There is no on-device fallback
capacity_unavailable or capacity_warming rather than returning a session that cannot carry audio. Retry shortly — warming is usually under a minute.Billing & usage
Your balance
Check your prepaid balance and spend from your own system.
/v1/balance{
"balance_usd": 84.20,
"total_spent_usd": 15.80,
"total_topped_up_usd": 100.00,
"spent_this_week_usd": 4.10,
"spent_this_month_usd": 15.80,
"spent_this_year_usd": 15.80
}Billing & usage
What you’ve spent
/v1/usage?limit&from&to[
{
"id": "…",
"endpoint": "face-swap-image",
"feature": "face_swap_image",
"status": "succeeded",
"charged_usd": 0.06,
"reason": null,
"created_at": "2026-08-15T09:31:07Z"
}
]limit defaults to 100 and tops out at 500 — ask for more explicitly if you want more.
reason is not a failure marker
session_limit_reached, no_stream, render_failed, timed_out, cancelled. A live session we ended cleanly at its funded cap succeeded and still carries one. Read status for the outcome and reason for the explanation./v1/usage/summary?from&toUse /usage/summary for totals — it is computed from your full history. The /usage log is capped, so summing it under-reports once you are busy.
Billing & usage
Rate card
Live rates, read from the API itself — this table cannot go stale. Public, no authentication required.
/v1/pricingno auth