Reference · v1

Everything the API will do, and what it costs.

Eight capabilities behind one bearer key. Rates on this page are read from the API as you look at them, so nothing here can go stale.

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.

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

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 it and create another instead. A revoked key stops working immediately.

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.

Base URL
https://api.kreatorsfactory.com/api/v1

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

POST/v1/uploadsmultipart/form-data
FieldTypeRequiredDescription
filefileYesAn 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.
Response
{
  "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

A key that does not begin 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:

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 a 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": "…" }
}
FieldTypeRequiredDescription
statusstringNoqueued, processing, succeeded or failed. A job we cancelled internally reports as failed — from outside, that is what it is.
output_urlstring | nullNoA signed, expiring link. Null until the job succeeds.
charged_usdnumber | nullNoNull until the call has been reconciled. Reporting a figure before billing settles would mean reporting one we might revise.
errorobject | nullNoA stable code and a human message, on a failed job only.

charged_usd is null before it settles

Treat it as nullable in your client. It fills in once the job has gone terminal and been reconciled — usually on the poll that first sees the terminal state, which is also the request that triggers the settlement.

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.

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.

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

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.

Three headers ride along with every delivery:

FieldTypeRequiredDescription
KreatorsFactory-SignaturestringNot=<unix>,v1=<hex>. Verify this before trusting the body.
KreatorsFactory-EventstringNoSame value as event in the body — lets you route without parsing.
KreatorsFactory-DeliverystringNoSame value as delivery_id. Stable for a job across retries.

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.

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.

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

Your 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

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

Safe to retry
-H "Idempotency-Key: your-own-unique-id"

Job submits, not session opens

The six job endpoints honour this header. Opening a live session does not take one: a session is not idempotent work, and it already costs nothing until you connect and stream.

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.

Response
{
  "error": {
    "code": "insufficient_balance",
    "message": "Your API balance is too low for this call. Top up to continue."
  }
}

On the response

HTTPCodeMeaning
401unauthorizedMissing, unknown or revoked key.
402insufficient_balanceYour balance will not cover the estimated hold. Top up to continue.
400invalid_inputA field failed validation.
400unsupported_formatThat file type is not accepted.
400input_too_largeOver the size limit for its type.
400capability_unavailableThis capability is currently switched off.
404not_foundNo such job, or it is not yours.
429rate_limitedYou are sending too fast. Back off and retry.
429at_capacityThe platform is briefly saturated. Retry shortly.
502capacity_unavailableNo capacity for a live session right now.
502capacity_warmingVoice capacity is starting up. Retry in about a minute.
502 / 500internal_errorFailed 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.

CodeMeaning
face_not_detectedNo face was found in the input.
unsupported_formatThe file could not be decoded once work started.
input_too_largeOver a size or duration limit found during processing.
content_rejectedThe input or the result failed a content check.
processing_failedThe 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.

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.

Capabilities

Video Swap

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. Anything else is rejected with invalid_input.
callback_urlstringNoWe POST the terminal status here when the job finishes. See Webhooks.

Capabilities

Photo Swap

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

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.

Capabilities

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 you omit it.
languagestringNoLanguage hint for the voice.
output_resolution_pintNo480, 720 or 1080.
callback_urlstringNoWe POST the terminal status here when the job finishes. See Webhooks.

Capabilities

Lip Sync

Match a video’s mouth movement to audio you supply.

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.

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.

POST/v1/full-live-swap/session
FieldTypeRequiredDescription
duration_minutesintYesMinutes to fund. 1–240. The stream hard-stops at the granted ceiling.
promptstringNoOptional swap instruction, up to 2,000 characters. A sensible default is used if you omit it.
Response
{
  "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

When capacity is tight the session is sized to what we can actually fund and steps down rather than failing outright, so you get a shorter session instead of an error. 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.

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.

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.

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_idstringNoAn id from GET /v1/voices. Omit to pass audio through unchanged — useful for measuring round-trip latency before you pick a voice.
Response
{
  "session_id": "…",
  "stream_url": "wss://api.kreatorsfactory.com/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 describing the audio format to use in both directions:

Response
{ "sample_rate": <int>, "chunk_frames": <int> }

Read the format off this frame — do not hardcode it

These values come from the engine handling your session and can differ between sessions. Wait for this frame, then use the values it gives you.

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

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

Failed or cancelled, the whole hold goes back and 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

Not when you connect. Warm-up before the engine produces anything is our cost, not yours — so a session that connects and never streams settles at zero.

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.

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 and 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. from and to bound the range as [from, to).

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

GET/v1/pricingauth optional

Send your key and you get your own rates

Authentication is optional here and it changes the answer. Without a key you get the public list. With one you get the rates that apply to your account, resolved by the same code the billing path uses — so if you are on a negotiated price, this is where you see it rather than being quoted one number and charged another.

Ready to build?

Create a key and add funds in the console.