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.

Your first call
# 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.

Header
Authorization: Bearer lmr_live_xxxxxxxxxxxxxxxxxxxxxxxx

Keep your key secret

Your key spends your balance. Call the API from your own server only — never from browser or mobile code, where anyone can read it. Keys are shown once and stored only as a hash, so we cannot recover one for you; revoke and create another instead. A revoked key stops working immediately.

Start here

Endpoints & versions

Base URL
https://api.lumarlabs.ai/api/v1

How 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.

POST/v1/uploadsmultipart/form-data
FieldTypeRequiredDescription
filefileYesImage, audio or video. Type is detected from the content type.
Response
{
  "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:

Response
{
  "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.

GET/v1/jobs/{job_id}
Response
{
  "id": "…",
  "status": "queued | processing | succeeded | failed",
  "output_url": "https://…",
  "charged_usd": 0.06,
  "error": { "code": "face_not_detected", "message": "…" }
}

Output URLs expire

Results are signed links with a limited lifetime. Download the output and store it on your own infrastructure rather than linking to it long-term.

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.

Request
-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.

Response
{
  "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

Anyone who learns your URL can post “your job succeeded, here is the file”. Check the signature before you trust a body — and reject it if it does not match, rather than logging and carrying on.

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.

Node.js — Express
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);
});
Python — Flask
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 "", 200

Your 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

FieldTypeRequiredDescription
Success2xxNoAny 2xx counts as delivered. Acknowledge first and do the work after — a slow handler is a timed-out delivery.
Retries6 attemptsNoAnything else is retried with exponential backoff over roughly fifteen minutes, then abandoned.
Timeout10sNoWe wait ten seconds for your response before treating it as a failure.
delivery_idstringNoStable for a job across retries. Key on it if you want to be certain you act once.

Webhooks do not replace the job endpoint

If your receiver was down for the whole retry window, the job is still there — 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.

Safe to retry
-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.

HTTPCodeMeaning
401unauthorizedMissing, unknown or revoked key.
402insufficient_balanceTop up to continue.
400invalid_inputA field failed validation.
400unsupported_formatThat file type isn't accepted.
400input_too_largeOver the size or duration limit.
400capability_unavailableCurrently switched off.
404not_foundNo such job, or not yours.
429rate_limitedSlow down and retry.
429at_capacityBriefly saturated — retry shortly.
503capacity_unavailableNo capacity for a live session right now.
503capacity_warmingVoice capacity is starting up — retry in about a minute.
500internal_errorFailed on our side. Nothing was billed; retry, and tell us if it persists.
face_not_detectedJob-level: no face in the input.
content_rejectedJob-level: failed a content check.
processing_failedJob-level: the render didn't complete.

Features

Character Swap

Put your character into a reference video, keeping its motion.

POST/v1/character-swap
FieldTypeRequiredDescription
video_keystringYesfile_key of the reference video.
character_keystringYesfile_key of the character image.
resolutionstringNo1k or 2k. Defaults to 1k.
callback_urlstringNoWe POST the terminal status here when the job finishes. See Webhooks.

Features

Face Swap — video

Swap a face into a video.

POST/v1/face-swap
FieldTypeRequiredDescription
video_keystringYesfile_key of the source video.
face_keystringYesfile_key of the face to swap in.
output_resolution_pintNo480, 720 or 1080.
callback_urlstringNoWe POST the terminal status here when the job finishes. See Webhooks.

Features

Face Swap — image

Swap a face into a single image.

POST/v1/face-swap-image
FieldTypeRequiredDescription
image_keystringYesfile_key of the base image.
face_keystringYesfile_key of the face to swap in.
callback_urlstringNoWe POST the terminal status here when the job finishes. See Webhooks.
Request
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.

POST/v1/motion-control
FieldTypeRequiredDescription
image_keystringYesfile_key of the character image.
motion_video_keystringYesfile_key of the motion reference.
callback_urlstringNoWe POST the terminal status here when the job finishes. See Webhooks.

Features

Avatar

A talking avatar from one portrait and a script.

POST/v1/avatar
FieldTypeRequiredDescription
image_keystringYesfile_key of the source portrait.
scriptstringYesWhat the avatar says. Up to 5,000 characters.
voice_idstringNoA specific voice. A default is chosen if omitted.
languagestringNoLanguage hint for the voice.
output_resolution_pintNo480, 720 or 1080.
callback_urlstringNoWe POST the terminal status here when the job finishes. See Webhooks.

Features

Lip Sync

Match a video’s mouth movement to your audio.

POST/v1/lip-sync
FieldTypeRequiredDescription
video_keystringYesfile_key of the source video.
audio_keystringYesfile_key of the audio to sync to.
output_resolution_pintNo480, 720 or 1080.
callback_urlstringNoWe 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.

POST/v1/full-live-swap/session
FieldTypeRequiredDescription
duration_minutesintYesMinutes to fund. The stream hard-stops here. 1–240.
promptstringNoOptional swap instruction. Up to 2,000 characters. A sensible default is used if omitted.
Response
{
  "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.

WS{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

There is no endpoint to stop a session. Disconnecting is what ends and bills it, and it settles on the seconds streamed up to that point. It also stops on its own at 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.

GET/v1/voices
Response
[
  {
    "id": "8f2c…",
    "name": "Narrator",
    "description": "Warm, measured.",
    "preview_url": "https://…/preview.mp3"
  }
]
POST/v1/voice/session
FieldTypeRequiredDescription
duration_minutesintNoMinutes to fund. The session hard-stops here. 1–120, default 10.
voice_profile_idstringNoid from GET /v1/voices. Omit to pass audio through unchanged — useful for measuring latency before picking a voice.
Response
{
  "session_id": "…",
  "stream_url": "wss://api.lumarlabs.ai/realtime",
  "session_token": "rt_…",
  "max_duration_sec": 600,
  "max_cost_usd": 3.60
}
WS{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:

Response
{ "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

There is no handshake for you to implement. The engine needs the target voice before it can convert, and we send it for you when the socket opens.

Billed by the second, like Full Live Swap

Nothing is debited when you open the session. You are charged for the seconds you actually stream, so a session that never connects costs nothing.

There is no on-device fallback

If we have no capacity free the call fails with 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.

GET/v1/balance
Response
{
  "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

GET/v1/usage?limit&from&to
Response
[
  {
    "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

It is a short slug for why a call ended — 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.
GET/v1/usage/summary?from&to

Use /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.

GET/v1/pricingno auth

Ready to build?

Create a key and add funds in the console.

Open the console