Start here
What you get
This API puts the KreatorsFactory face and video engines behind an HTTP call. You send an image, a clip or a live stream; you get back the same thing with a different face or voice on it. Eight capabilities, one key, nothing to install and no GPU to keep warm.
There are two shapes of call. Six capabilities are jobs: you submit one, poll it, and collect a signed URL when it finishes. Two are sessions: you open one, stream through it over a socket, and it settles on the seconds you actually sent.
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 job that fails costs nothing.
Start here
Your first call
Three steps: upload an asset, submit a job, poll for the result. This is a complete working example — paste it into a terminal with your key exported.
# 1 · Upload each asset and keep the file_key it returns
IMAGE_KEY=$(curl -s https://api.kreatorsfactory.com/api/v1/uploads \
-H "Authorization: Bearer $KREATORSFACTORY_API_KEY" \
-F "file=@base.png" | jq -r .file_key)
FACE_KEY=$(curl -s https://api.kreatorsfactory.com/api/v1/uploads \
-H "Authorization: Bearer $KREATORSFACTORY_API_KEY" \
-F "file=@face.png" | jq -r .file_key)
# 2 · Submit the job
JOB=$(curl -s https://api.kreatorsfactory.com/api/v1/face-swap-image \
-H "Authorization: Bearer $KREATORSFACTORY_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"image_key\":\"$IMAGE_KEY\",\"face_key\":\"$FACE_KEY\"}")
# 3 · Poll the status_url the submit handed back
curl -s "https://api.kreatorsfactory.com$(echo "$JOB" | jq -r .status_url)" \
-H "Authorization: Bearer $KREATORSFACTORY_API_KEY"Poll until status is succeeded or failed, or skip polling entirely by passing a callback_url — see Webhooks.
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 and versions
Every path on this page hangs off one base. The version is in the path, so a future v2 can run alongside this one rather than replacing it underneath you.
https://api.kreatorsfactory.com/api/v1How it works
Sending files
Capability calls reference their inputs by file_key, never by URL. Upload each asset first and pass back the key you get. We host the upload, so there is no CORS or presign dance — and we never fetch an external URL you hand us, which is what keeps this from being an SSRF hole in your product.
/v1/uploadsmultipart/form-data| Field | Type | Required | Description |
|---|---|---|---|
file | file | Yes | An image, audio or video file. The type is read from the Content-Type, falling back to the filename's extension when your client does not set one. |
{
"file_key": "api/images/3f2a…/9f8c1d2e3a4b.png",
"url": "https://…"
}Images and audio up to 45 MB, video up to 500 MB. Over that you get input_too_large, and the upload is refused as it streams rather than after we have buffered the whole thing.
Pass the key back exactly as you received it
api/ is rejected at submit with invalid_input. That check exists so a malformed key fails immediately instead of queueing a job that could only ever fail — which used to cost a slot and several minutes before telling you anything.How it works
Getting results back
Every capability except the two live sessions is asynchronous. A submit returns immediately with an id, and you poll its status URL until it reaches a terminal state. Whichever capability you called, a submit answers with the same three fields:
{
"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 a 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": "…" }
}| Field | Type | Required | Description |
|---|---|---|---|
status | string | No | queued, processing, succeeded or failed. A job we cancelled internally reports as failed — from outside, that is what it is. |
output_url | string | null | No | A signed, expiring link. Null until the job succeeds. |
charged_usd | number | null | No | Null until the call has been reconciled. Reporting a figure before billing settles would mean reporting one we might revise. |
error | object | null | No | A stable code and a human message, on a failed job only. |
charged_usd is null before it settles
Output URLs expire
How it works
Webhooks
Rather than polling, pass a callback_url when you submit and we will 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/kreatorsfactory"}'The body carries the same object GET /v1/jobs/{id} returns, under data — byte for byte, from the same serialiser — so the handler you already wrote for polling takes 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.
Three headers ride along with every delivery:
| Field | Type | Required | Description |
|---|---|---|---|
KreatorsFactory-Signature | string | No | t=<unix>,v1=<hex>. Verify this before trusting the body. |
KreatorsFactory-Event | string | No | Same value as event in the body — lets you route without parsing. |
KreatorsFactory-Delivery | string | No | Same value as delivery_id. Stable for a job across retries. |
Verify every delivery
An unverified endpoint is an open door
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, because key order and spacing shift and the digest no longer describes what arrived.
import crypto from "node:crypto";
// express.raw() — NOT express.json(). The signature covers the bytes we sent.
app.post("/hooks/kreatorsfactory", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("KreatorsFactory-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", process.env.KREATORSFACTORY_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/kreatorsfactory")
def kreatorsfactory_hook():
header = request.headers.get("KreatorsFactory-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 is on the Keys screen in the console, beside your API keys. It is per-account, so it keeps working when you rotate an API key — rotate it separately if it is ever exposed. It is minted the first time you ask for it, rather than created with your account, because a credential nobody requested is one more thing that can leak.
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 because a delivery failed.How it works
Safe retries
Send an Idempotency-Key header on any job 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"Job submits, not session opens
How it works
Throughput caps
60 requests per minute per key by default, as a fixed window. Exceeding it returns 429 with code rate_limited. The limit is a property of your key, so raising it does not need a deploy on our side — ask.
Separately, there is a ceiling on how much work the API can hold in flight at once, and on how many live sessions can run concurrently. Hitting either returns 429 with at_capacity.
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, and retrying shortly is the right move rather than slowing your client down. They look alike and need different responses.How it works
When a call fails
Every error arrives in the same envelope, whatever produced it. Branch on code — the prose may change, the codes will not.
{
"error": {
"code": "insufficient_balance",
"message": "Your API balance is too low for this call. Top up to continue."
}
}On the response
| HTTP | Code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing, unknown or revoked key. |
| 402 | insufficient_balance | Your balance will not cover the estimated hold. Top up to continue. |
| 400 | invalid_input | A field failed validation. |
| 400 | unsupported_format | That file type is not accepted. |
| 400 | input_too_large | Over the size limit for its type. |
| 400 | capability_unavailable | This capability is currently switched off. |
| 404 | not_found | No such job, or it is not yours. |
| 429 | rate_limited | You are sending too fast. Back off and retry. |
| 429 | at_capacity | The platform is briefly saturated. Retry shortly. |
| 502 | capacity_unavailable | No capacity for a live session right now. |
| 502 | capacity_warming | Voice capacity is starting up. Retry in about a minute. |
| 502 / 500 | internal_error | Failed on our side. Nothing is billed; retry, and tell us if it persists. |
The capacity errors are 502, not 503
capacity_unavailable, capacity_warming and internal_error are all raised the same way and arrive as 502. internal_error is also a genuine 500 when something unhandled goes wrong. If your retry logic keys on the status rather than the code, treat both as retryable.On the job
These arrive inside error on a job that reached failed, not as an HTTP status. The call that submitted the work succeeded; the work did not.
| Code | Meaning |
|---|---|
face_not_detected | No face was found in the input. |
unsupported_format | The file could not be decoded once work started. |
input_too_large | Over a size or duration limit found during processing. |
content_rejected | The input or the result failed a content check. |
processing_failed | The render did not complete. |
A job that fails is refunded in full, whichever of these it carries.
Capabilities
Identity Swap
Put your character into a reference video, keeping the reference’s motion. This replaces the whole appearance rather than only the face.
/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. |
Capabilities
Video Swap
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. Anything else is rejected with invalid_input. |
callback_url | string | No | We POST the terminal status here when the job finishes. See Webhooks. |
Capabilities
Photo Swap
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.kreatorsfactory.com/api/v1/face-swap-image \
-H "Authorization: Bearer $KREATORSFACTORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"image_key":"api/images/…","face_key":"api/images/…"}'Capabilities
Motion Transfer
Animate a still character image so it follows 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. |
Capabilities
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 you omit it. |
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. |
Capabilities
Lip Sync
Match a video’s mouth movement to audio you supply.
/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. |
Capabilities
Live Swap Pro
Real-time face swap over a live stream. Unlike the capabilities above 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. 1–240. The stream hard-stops at the granted ceiling. |
prompt | string | No | Optional swap instruction, up to 2,000 characters. A sensible default is used if you omit it. |
{
"session_id": "…",
"stream_url": "wss://api.kreatorsfactory.com/realtime",
"session_token": "rt_…",
"prompt": "…",
"max_duration_sec": 600,
"max_cost_usd": 25.00
}Read max_duration_sec — you may be granted less than you asked for
max_duration_sec is authoritative and max_cost_usd is computed from it. Do not assume you received duration_minutes × 60.prompt also 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.Capabilities
Voice Swap
Real-time voice conversion. Like Live Swap Pro 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 | An id from GET /v1/voices. Omit to pass audio through unchanged — useful for measuring round-trip latency before you pick a voice. |
{
"session_id": "…",
"stream_url": "wss://api.kreatorsfactory.com/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 describing the audio format to use in both directions:
{ "sample_rate": <int>, "chunk_frames": <int> }Read the format off this frame — do not hardcode it
After that it is audio both ways: send raw mono PCM (int16, little-endian, at the sample_rate you were given) 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 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 and usage
How billing settles
You top up a balance in advance and calls draw against it. There is no subscription and no minimum to clear. What follows is the whole model.
Jobs
A submit places a conservative hold, not a charge. Your balance has to cover that hold or the submit returns insufficient_balance — note that the hold is an estimate and is usually larger than the final price, so the figure you need available is not the rate on the rate card.
When the job goes terminal it is reconciled and the difference is settled either way: excess is returned to your balance, a shortfall is collected. The charge is max(minimum charge, rate × units), and units are measured on the output, not on what you uploaded — a per-second capability bills the duration of the video it produced.
A failed job charges nothing
charged_usd settles at 0. We do not bill for work that produced no result, whatever it cost us to attempt.Sessions
Nothing is debited when you open a session. We check your balance covers the reserved block, hand you a socket, and charge on close for the seconds actually streamed at seconds × (per-minute rate ÷ 60). There is no minimum charge on a session.
The meter starts at the first converted frame
Rates, minimum charges and the estimated hold are all admin settings, readable at any time from the rate card.
Billing and 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 and 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. from and to bound the range as [from, to).
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&to{
"total_calls": 412,
"succeeded": 400,
"failed": 9,
"in_flight": 3,
"total_spent_usd": 128.44,
"per_feature": [
{ "feature": "face_swap_image", "calls": 380, "spend_usd": 22.80 }
]
}Use /usage/summary for totals — it is computed from your full history, while the /usage log is capped, so summing that under-reports once you are busy. in_flight is queued or processing work: the three counts add up to total_calls, so calls still running are not quietly reported as successes.
Billing and usage
Rate card
Live rates, read from the API itself — this table cannot go stale.
/v1/pricingauth optionalSend your key and you get your own rates
Ready to build?
Create a key and add funds in the console.
