Start here
What you get
This API puts FazeSwap’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.fazeswap.com/api/v1/uploads \
-H "Authorization: Bearer $FAZESWAP_API_KEY" \
-F "file=@base.png" | jq -r .file_key)
# 2 · Submit the job
JOB=$(curl -s https://api.fazeswap.com/api/v1/face-swap-image \
-H "Authorization: Bearer $FAZESWAP_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.fazeswap.com$(echo "$JOB" | jq -r .status_url)" \
-H "Authorization: Bearer $FAZESWAP_API_KEY"Start here
Using your key
Send your key as a bearer token on every request.
Authorization: Bearer fzs_live_xxxxxxxxxxxxxxxxxxxxxxxxKeep your key secret
Start here
Endpoints & versions
https://api.fazeswap.com/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/fazeswap"}'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 FazeSwap-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/fazeswap", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("FazeSwap-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", process.env.FAZESWAP_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/fazeswap")
def fazeswap_hook():
header = request.headers.get("FazeSwap-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.
The reference video’s audio is kept.The render is a new video generated from your character image and the reference clip’s motion, and we re-attach the reference clip’s soundtrack to it — so a person dancing to a song becomes your character dancing to that same song, still audible. If your reference clip has no audio, the output is silent.
/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.fazeswap.com/api/v1/face-swap-image \
-H "Authorization: Bearer $FAZESWAP_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.
The reference video’s audio is kept.The render is newly generated frames, so we re-attach the reference clip’s soundtrack to it. If your clip has no audio, the output is silent.
/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
Voice Cloner
Clone a voice from a short sample and have it read any script. One request, one audio file back.
/v1/voice-cloneThis is the one capability priced on textrather than output length, so the cost is settled before the render starts: characters submitted ÷ 1,000, at the rate above. A failed render refunds in full, as everywhere else.
Long scripts are split at sentence ends and stitched back together on our side — you send the whole script, you get one file. Up to 45,000 characters per call, roughly 30 minutes of speech.
| Field | Type | Required | Description |
|---|---|---|---|
voice_key | string | Yes | file_key of the reference voice. 3-15 seconds of clean, continuous speech from one speaker clones best. |
script | string | Yes | What the cloned voice should say. Up to 45,000 characters. |
language | string | No | Language hint, e.g. 'en'. Omit to let the model detect it. |
callback_url | string | No | We POST the terminal status here when the job finishes. See Webhooks. |
Features
Full Live Swap
ProStandardReal-time face swap over a live stream. Unlike the others this is a session, not a job: create one, connect over WebSocket, and stream.
It comes in two tiers, chosen per session with tier. They are interchangeable in your code — same request, same socket, same frames — so you can start on one and move to the other by changing a single field.
Full Live Swap Pro is the one to build on. It is our highest-fidelity live engine, it is what runs when you send no tier at all, and it is the tier that supports browser extension delivery — if you want your users swapping faces inside their existing video calls rather than in an app you have to get them to install, talk to us and we will help you build it.
Standard is there for when cost matters more than the last increment of quality — long-running sessions, high volume, or a prototype you do not want to fund at production rates.
| tier | Rate card entry | What you get |
|---|---|---|
proRecommended | full_live_swap | Our highest-fidelity live engine, and the default — omit tier and you get this.Supports browser extension — learn more |
standard | full_live_swap_standard | Lower cost per minute. Built for long sessions, high volume, and prototyping. |
The rate card key for Pro is full_live_swap without a suffix — it predates the tiers and is kept so existing usage history stays valid. Both rates are live in the rate card, and your own negotiated rate on one tier does not apply to the other.
/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. |
tier | string | No | pro (default) or standard. See the table above — omitting it keeps today's behaviour. |
{
"session_id": "…",
"stream_url": "wss://api.fazeswap.com/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.A tier that is unavailable fails — it does not switch
capacity_unavailable and are charged nothing. We do not quietly serve the other tier: you would be billed at a rate you did not pick, on an engine you did not choose.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.fazeswap.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 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