# faceswap.ing API

> Launch face swaps on photos and videos from your own code with an API key, then poll the task or receive a signed webhook and download the results. Base URL: https://faceswap.ing/api/v1.

- OpenAPI 3.1 document: https://faceswap.ing/api/v1/openapi.json
- Interactive reference: https://faceswap.ing/docs/api/endpoints
- Agent Skill for Claude: https://faceswap.ing/docs/api/skill.md (instructions: https://faceswap.ing/docs/api/agents)
- Quickstart updated 2026-11-05, guide updated 2026-11-10.

## Quickstart

From an API key to downloaded results, in the order a pipeline does it: check the key, launch, wait or take a webhook, download. Every sample is plain HTTP with fetch or requests, with no SDK to install, and reads the key from FACESWAP_API_KEY. The samples build on each other: a later one reuses API, headers and the functions defined before it.

### Get a key and check it

Make a key under Settings, API keys, and pick the team it spends from. The key sees that team's faces, tasks and upscales and nothing else. The full key is shown once, so copy it then.

Keep it in an environment variable or your secret store. Every sample here reads FACESWAP_API_KEY and sends it in the x-api-key header. Do not put it in front-end code, where anyone can read it.

Before the first launch, the account holder opens the app once and confirms that they are an adult and that everyone whose face they use has agreed. Until then every launch is refused with declaration_required.

This read costs nothing. A 200 with a page of faces means the key works; a 401 means it is missing, mistyped or revoked.

**Check the key, curl**

```bash
curl "https://faceswap.ing/api/v1/faces?pageSize=1" \
  -H "x-api-key: $FACESWAP_API_KEY"
```

**Check the key, JavaScript**

```js
const API = "https://faceswap.ing/api/v1";
const headers = { "x-api-key": process.env.FACESWAP_API_KEY };

const response = await fetch(`${API}/faces?pageSize=1`, { headers });

console.log(response.status, await response.json());
```

**Check the key, Python**

```python
import os
import time

import requests

API = "https://faceswap.ing/api/v1"
HEADERS = {"x-api-key": os.environ["FACESWAP_API_KEY"]}

response = requests.get(f"{API}/faces", headers=HEADERS, params={"pageSize": 1}, timeout=30)

print(response.status_code, response.json())
```

### Host the files

There is no upload route. A face photo and every target are public HTTPS URLs that our service downloads while the swap runs, so they have to answer without any header of yours. A presigned URL from S3, R2 or Google Cloud Storage works if it stays valid for a few hours.

The path has to end in the file's extension, because that is how the API tells a photo from a video and prices it: .jpg, .jpeg, .png, .webp, .bmp, .heic, .heif for photos, .mp4, .mov, .avi, .mkv, .webm, .gif for video. A query string after the extension, as presigned URLs carry, is fine.

Size, length and frame limits are listed under Media in the guide. A file over a limit is refused at launch, before anything is held.

### Launch a swap

POST /faceswap/tasks with the face and up to 200 targets. Photos and videos can share a launch. The launch screens every file you host before it prices anything, so give the request a timeout of a minute or more.

A launch that ran answers 200, and data lists the tasks it created. Keep every taskId: a large or mixed launch is split into several tasks that run in parallel. A launch refused before it created anything answers an error status with an application/problem+json body: branch on its code, as Errors below shows. One refused part-way answers 200 with the tasks it did create in data and the reason in refusal, which is null when the whole launch ran.

The swapz for the whole launch are held from the team's wallet when it starts. When a task ends, what completed is charged and every file that failed is refunded.

In a pipeline, send each launch with an Idempotency-Key so a retry cannot launch it twice. Retries and duplicates below shows how.

**Launch, curl**

```bash
curl -X POST "https://faceswap.ing/api/v1/faceswap/tasks" \
  -H "x-api-key: $FACESWAP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "faceImageUrl": "https://example.com/face.jpg",
    "targetFiles": [{ "url": "https://example.com/photo.jpg" }]
  }'
```

**Launch, JavaScript**

```js
const API = "https://faceswap.ing/api/v1";
const headers = { "x-api-key": process.env.FACESWAP_API_KEY };

const response = await fetch(`${API}/faceswap/tasks`, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({
    faceImageUrl: "https://example.com/face.jpg",
    targetFiles: [{ url: "https://example.com/photo.jpg" }],
  }),
  // Every file is screened before anything is priced: allow a minute.
  signal: AbortSignal.timeout(120_000),
});

const launch = await response.json();

// A refusal is an application/problem+json document: branch on its code.
if (!response.ok) throw new Error(`${response.status} ${launch.code}: ${launch.detail ?? launch.title}`);

// Set when the launch stopped part-way: data still lists the tasks it created.
if (launch.refusal) console.warn(launch.refusal.code, launch.refusal.detail);

const taskIds = launch.data.map((task) => task.taskId);
```

**Launch, Python**

```python
import os
import time

import requests

API = "https://faceswap.ing/api/v1"
HEADERS = {"x-api-key": os.environ["FACESWAP_API_KEY"]}

response = requests.post(
    f"{API}/faceswap/tasks",
    headers=HEADERS,
    json={
        "faceImageUrl": "https://example.com/face.jpg",
        "targetFiles": [{"url": "https://example.com/photo.jpg"}],
    },
    # Every file is screened before anything is priced: allow a minute.
    timeout=120,
)
launch = response.json()

# A refusal is an application/problem+json document: branch on its code.
if not response.ok:
    raise RuntimeError(f"{response.status_code} {launch['code']}: {launch.get('detail', launch['title'])}")

# Set when the launch stopped part-way: data still lists the tasks it created.
if launch["refusal"]:
    print(launch["refusal"]["code"], launch["refusal"].get("detail"))

task_ids = [task["taskId"] for task in launch["data"]]
```

**Response, shortened**

```json
{
  "data": [
    {
      "taskId": "01a08066-5d70-7000-a3db-ff3ef6b651ca",
      "status": "queued",
      "files": [
        {
          "id": "01a08066-5d70-7fff-9e92-493cc98e0da6",
          "status": "pending",
          "targetUrl": "https://example.com/photo.jpg",
          "outputUrl": null
        }
      ]
    }
  ],
  "refusal": null
}
```

### Wait for the result

Read GET /faceswap/tasks/{taskId} every 5 to 10 seconds until status is cancelled, completed, failed, partially_completed. Reads cost nothing.

A key may make 120 reads every 60 seconds. With more than a few tasks in flight, read GET /faceswap/tasks?status=queued,processing instead: one request returns up to 100 tasks. Or skip polling and take a webhook.

**Wait for a task to end, curl**

```bash
curl "https://faceswap.ing/api/v1/faceswap/tasks/01a08066-5d70-7000-a3db-ff3ef6b651ca" \
  -H "x-api-key: $FACESWAP_API_KEY"
```

**Wait for a task to end, JavaScript**

```js
const TERMINAL = new Set(["cancelled", "completed", "failed", "partially_completed"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function waitFor(taskId) {
  for (;;) {
    const response = await fetch(`${API}/faceswap/tasks/${taskId}`, { headers });

    if (response.status === 429) {
      await sleep(Number(response.headers.get("retry-after") ?? 5) * 1000);
      continue;
    }

    if (!response.ok) throw new Error(`Task read answered ${response.status}`);

    const task = await response.json();

    if (TERMINAL.has(task.status)) return task;

    await sleep(5000);
  }
}
```

**Wait for a task to end, Python**

```python
TERMINAL = {"cancelled", "completed", "failed", "partially_completed"}


def wait_for(task_id: str) -> dict:
    while True:
        response = requests.get(f"{API}/faceswap/tasks/{task_id}", headers=HEADERS, timeout=30)

        if response.status_code == 429:
            time.sleep(int(response.headers.get("Retry-After", "5")))
            continue

        response.raise_for_status()
        task = response.json()

        if task["status"] in TERMINAL:
            return task

        time.sleep(5)
```

### Or take a webhook

Register one HTTPS endpoint per key under Settings, API keys, in the key's menu. Saving it shows a signing secret once. When a task launched with that key ends, we POST the task to the endpoint, in the same shape GET /faceswap/tasks/{taskId} returns, inside an event.

Deliveries are signed the Standard Webhooks way (standardwebhooks.com), so any of its libraries verifies one with your secret as it is shown: standardwebhooks for JavaScript and Python, for one, or Svix's. To check by hand, as below, verify the raw body before you parse it. webhook-signature is v1, a comma and the base64 HMAC-SHA256 of webhook-id, a full stop, webhook-timestamp, a full stop and the body, keyed with the bytes your secret encodes after whsec_. It may list several signatures separated by spaces: accept the delivery when one matches. Refuse a timestamp more than 5 minutes from your clock.

Answer 2xx within 10 seconds and do the work afterwards, from your own queue. Anything else is retried, 6 attempts in all. Delivery is at least once, so store webhook-id and skip an id you have seen.

After 10 failures in a row the endpoint is switched off until somebody turns it back on in settings. Events from that time are not resent, so a pipeline that relies on webhooks should still list recent tasks once a day.

**Verify a delivery, JavaScript**

```js
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody is the request body exactly as received, before any JSON parsing.
// secret is the signing secret as shown, whsec_...
export function verify(rawBody, headers, secret) {
  const id = headers["webhook-id"] ?? "";
  const timestamp = Number(headers["webhook-timestamp"]);
  const signatures = (headers["webhook-signature"] ?? "").split(" ");

  // Refuse a delivery older than 5 minutes: a replay carries an old timestamp.
  if (!(Math.abs(Date.now() / 1000 - timestamp) <= 300)) return false;

  // The key is the bytes the secret encodes, after its prefix.
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = Buffer.from(
    createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64"),
  );

  // The header may list more than one signature, as "v1,<base64>" each.
  return signatures.some((one) => {
    const [version, value = ""] = one.split(",");
    const given = Buffer.from(value);

    return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

**Verify a delivery, Python**

```python
import base64
import hashlib
import hmac
import time

# raw_body is the request body exactly as received, as bytes.
# secret is the signing secret as shown, whsec_...
def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
    webhook_id = headers.get("webhook-id", "")
    timestamp = headers.get("webhook-timestamp", "")
    signatures = headers.get("webhook-signature", "").split(" ")

    # Refuse a delivery older than 5 minutes: a replay carries an old timestamp.
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False

    # The key is the bytes the secret encodes, after its prefix.
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = webhook_id.encode() + b"." + timestamp.encode() + b"." + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    # The header may list more than one signature, as "v1,<base64>" each.
    return any(
        version == "v1" and hmac.compare_digest(value, expected)
        for version, _, value in (one.partition(",") for one in signatures)
    )
```

**Receive it, JavaScript**

```js
import express from "express";

const app = express();

// The raw bytes: the signature covers the body exactly as it was sent.
app.post("/faceswap-events", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");

  if (!verify(rawBody, req.headers, process.env.FACESWAP_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(rawBody);

  // Store event.id first: a retry carries the same one.
  // Then queue the work and answer at once.
  res.sendStatus(200);
});
```

**Receive it, Python**

```python
import json
import os

from flask import Flask, request

app = Flask(__name__)


@app.post("/faceswap-events")
def faceswap_events():
    raw_body = request.get_data()  # the bytes the signature covers

    if not verify(raw_body, request.headers, os.environ["FACESWAP_WEBHOOK_SECRET"]):
        return "", 400

    event = json.loads(raw_body)

    # Store event["id"] first: a retry carries the same one.
    # Then queue the work and answer at once.
    return "", 200
```

### Download the results

Each completed file carries outputUrl, a plain HTTPS link that needs no key. A file that did not complete has a status and an errorCode saying why, and it costs nothing.

Copy results to your own storage. Generations and the files they were made from are deleted 30 days after the task ends; purgedAt on the task says when that happened.

**Download what completed, curl**

```bash
# outputUrl needs no key.
curl -L -o result.jpg "OUTPUT_URL_FROM_THE_TASK"
```

**Download what completed, JavaScript**

```js
import { writeFile } from "node:fs/promises";

const task = await waitFor(taskId);

for (const file of task.files) {
  if (file.status !== "completed" || !file.outputUrl) {
    console.warn(file.targetUrl, file.status, file.errorCode);
    continue;
  }

  // outputUrl needs no key.
  const output = await fetch(file.outputUrl);
  const name = new URL(file.outputUrl).pathname.split("/").pop();

  await writeFile(name, Buffer.from(await output.arrayBuffer()));
}
```

**Download what completed, Python**

```python
task = wait_for(task_id)

for file in task["files"]:
    if file["status"] != "completed" or not file["outputUrl"]:
        print(file["targetUrl"], file["status"], file["errorCode"])
        continue

    # outputUrl needs no key.
    output = requests.get(file["outputUrl"], timeout=300)
    output.raise_for_status()

    with open(file["outputUrl"].split("?")[0].rsplit("/", 1)[-1], "wb") as out:
        out.write(output.content)
```

### Errors, and what to do about them

Every error is an application/problem+json document. Branch on the HTTP status, then on code. detail is a sentence for a log, and its wording can change.

The guide lists every code a launch can be refused with, and every errorCode a failed file can carry.

| Answer | What happened | What to do |
| --- | --- | --- |
| 401 api_key_missing, api_key_invalid | The key is missing, mistyped, revoked or expired. | Fix the key. Retrying will not help. |
| 403 organization_suspended | The workspace the key belongs to is suspended. | Stop calling until it is lifted. |
| 404 not_found | The task is not in this key's team. | Check the id and the key. |
| 422 validation | The request does not match the schema. property points at the first field that failed. | Fix the request against the reference. |
| 429 rate_limited | Too many requests from this key in the current window. | Wait Retry-After seconds and send the same request. |
| 409 idempotency_key_in_progress | The first launch with this Idempotency-Key has not answered yet. | Wait Retry-After seconds and send the same request, with the same key. |
| 422 idempotency_key_reused | This Idempotency-Key was sent before with a different body. Nothing was launched. | Use a new key for a different launch. |
| 503 moderation_unavailable, media_checking, service_busy | A launch refused before anything was held, for now. | Wait Retry-After seconds (30) and send the same launch again. |
| 402 insufficient_swapz | The team does not hold what the launch costs. shortfall has both numbers. | Top up the wallet, then send it again. |
| 403 declaration_required | The account holder has not confirmed the declaration. | Open the app once and confirm it. |
| 400 moderation_refused, file_too_large, video_too_long and other limits | A file was refused, and will be refused again. | Change or drop that file. |
| 502 launch_failed | A fault on our side. Nothing was held. | Send it again later. |

### Rate limits

Each key may make 120 reads every 60 seconds and 30 launches every 60 seconds, counted apart. Windows are fixed to the clock. Past the limit the answer is 429 with a Retry-After header, and a refused request is not counted.

A launch carries up to 200 files, so a batch of a few hundred files is a handful of launches. Send them one after another and wait out a 429 when it comes. Two keys never share a counter.

### Retries and duplicates

Every GET is safe to repeat, and so is anything answered 429: a throttled request is refused before it is read.

Send every launch with an Idempotency-Key header: a new UUID for each launch, kept and sent unchanged with every retry of that launch. If the answer is lost to a timeout or a dropped connection, send the same request with the same key. When the first one created tasks, the retry is answered with its stored response and the header Idempotent-Replayed: true, and nothing is launched or held twice. A key is kept for 24 hours and belongs to the API key that sent it.

Only a launch that created tasks is stored. A launch refused before anything was held, such as insufficient_swapz, moderation_unavailable, service_busy or launch_failed, gives its key back, so the same request with the same key runs when you send it again after the cause is fixed. A launch refused part-way, with tasks in data, is stored like a success.

While the first request with a key is still running, a retry is answered 409 with idempotency_key_in_progress and a Retry-After header: wait that long and send it again. A key sent again with a different body is answered 422 with idempotency_key_reused and launches nothing, because a different launch needs a new key. Each retry counts against the launch limit.

A key that keeps answering 409 for several minutes belongs to a request that was cut off part-way, so whether it launched is unknown. List the newest tasks before you launch again under a new key. Without the header a launch is never deduplicated, and the same check applies before you resend one.

A webhook can arrive twice and out of order between tasks. Deduplicate on webhook-id, and treat a task's terminal status as final: it never changes again.

**Launch once, however often it is retried, curl**

```bash
# One key per launch. Sending this same command again, with the same
# KEY, is answered with the first launch's response instead of a second swap.
KEY=$(uuidgen)

curl -X POST "https://faceswap.ing/api/v1/faceswap/tasks" \
  -H "x-api-key: $FACESWAP_API_KEY" \
  -H "Idempotency-Key: $KEY" \
  -H "Content-Type: application/json" \
  --retry 5 --retry-connrefused --max-time 120 \
  -d '{
    "faceImageUrl": "https://example.com/face.jpg",
    "targetFiles": [{ "url": "https://example.com/photo.jpg" }]
  }'
```

**Launch once, however often it is retried, JavaScript**

```js
async function launchOnce(body) {
  // One key per launch, reused by every retry of it.
  const key = crypto.randomUUID();

  for (let attempt = 1; attempt <= 5; attempt += 1) {
    let response;

    try {
      response = await fetch(`${API}/faceswap/tasks`, {
        method: "POST",
        headers: { ...headers, "Content-Type": "application/json", "Idempotency-Key": key },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(120_000),
      });
    } catch {
      // No answer: it may or may not have launched. The same key makes it run once.
      await sleep(2000 * attempt);
      continue;
    }

    // 409: the first try is still running. 429: throttled. 503: not yet. 5xx: not stored.
    if (response.status === 409 || response.status === 429 || response.status >= 500) {
      await sleep(Number(response.headers.get("retry-after") ?? 5) * 1000);
      continue;
    }

    // A refusal that will repeat, as a problem document: 400, 402, 403, or 422 for a body
    // that does not match the schema or this key reused for another body.
    if (!response.ok) {
      const problem = await response.json();

      throw new Error(`Launch answered ${response.status} ${problem.code}: ${problem.detail ?? problem.title}`);
    }

    // "Idempotent-Replayed: true" when this is the first try's answer again.
    return response.json();
  }

  throw new Error(`No answer after 5 attempts; list recent tasks before launching again.`);
}

const result = await launchOnce({
  faceImageUrl: "https://example.com/face.jpg",
  targetFiles: [{ url: "https://example.com/photo.jpg" }],
});
```

**Launch once, however often it is retried, Python**

```python
import uuid


def launch_once(body: dict) -> dict:
    # One key per launch, reused by every retry of it.
    key = str(uuid.uuid4())

    for attempt in range(1, 6):
        try:
            response = requests.post(
                f"{API}/faceswap/tasks",
                headers={**HEADERS, "Idempotency-Key": key},
                json=body,
                timeout=120,
            )
        except requests.RequestException:
            # No answer: it may or may not have launched. The same key makes it run once.
            time.sleep(2 * attempt)
            continue

        # 409: the first try is still running. 429: throttled. 503: not yet. 5xx: not stored.
        if response.status_code in (409, 429) or response.status_code >= 500:
            time.sleep(int(response.headers.get("Retry-After", "5")))
            continue

        # A refusal that will repeat, as a problem document: 400, 402, 403, or 422 for a body
        # that does not match the schema or this key reused for another body.
        if not response.ok:
            problem = response.json()
            raise RuntimeError(f"Launch answered {response.status_code} {problem['code']}: {problem.get('detail')}")

        # "Idempotent-Replayed: true" when this is the first try's answer again.
        return response.json()

    raise RuntimeError("No answer after 5 attempts; list recent tasks before launching again.")


result = launch_once({
    "faceImageUrl": "https://example.com/face.jpg",
    "targetFiles": [{"url": "https://example.com/photo.jpg"}],
})
```

### Fit it into your pipeline

Batch jobs. Group targets by face and send up to 200 per launch, then wait on the task ids or on webhooks. The sample below reuses post, waitFor and wait_for from the steps above.

Queue workers. One message, one launch: store the taskIds against the message, and mark the job done when the webhook for each task arrives. A daily list of recent tasks catches anything a switched-off endpoint missed.

n8n, Make, Zapier and other HTTP steps. Use the tool's HTTP request step: method POST, URL https://faceswap.ing/api/v1/faceswap/tasks, a header x-api-key holding the key from the tool's credential store, and a JSON body like the launch above. Then either loop a delay and a GET on the task, or start a second flow from the tool's webhook trigger. To check the signature there, the trigger has to hand you the raw body.

Typed clients. The OpenAPI document at https://faceswap.ing/api/v1/openapi.json describes every route and field, so a generator such as openapi-typescript or OpenAPI Generator can build a client for your language.

**A batch: one face onto many targets, JavaScript**

```js
const FACE = "https://example.com/face.jpg";
const TARGETS = ["https://example.com/shots/001.jpg", "https://example.com/shots/002.mp4"];
const PER_LAUNCH = 200;

async function post(path, body) {
  for (;;) {
    const response = await fetch(`${API}${path}`, {
      method: "POST",
      headers: { ...headers, "Content-Type": "application/json" },
      body: JSON.stringify(body),
      signal: AbortSignal.timeout(120_000),
    });

    // 429 is throttled and 503 is "not yet": nothing ran, so wait and send it again.
    if (response.status !== 429 && response.status !== 503) return response;

    await sleep(Number(response.headers.get("retry-after") ?? 5) * 1000);
  }
}

const taskIds = [];

for (let start = 0; start < TARGETS.length; start += PER_LAUNCH) {
  const chunk = TARGETS.slice(start, start + PER_LAUNCH);
  const response = await post("/faceswap/tasks", {
    faceImageUrl: FACE,
    targetFiles: chunk.map((url) => ({ url })),
  });
  const launch = await response.json();

  // Refused before it created anything: nothing runs and nothing is held.
  if (!response.ok) {
    console.error(response.status, launch.code, launch.detail);
    continue;
  }

  // A launch refused part-way still lists the tasks it created.
  taskIds.push(...launch.data.map((task) => task.taskId));

  if (launch.refusal) console.error(launch.refusal.code, launch.refusal.detail);
}

const tasks = await Promise.all(taskIds.map(waitFor));
```

**A batch: one face onto many targets, Python**

```python
FACE = "https://example.com/face.jpg"
TARGETS = ["https://example.com/shots/001.jpg", "https://example.com/shots/002.mp4"]
PER_LAUNCH = 200


def post(path: str, body: dict) -> requests.Response:
    while True:
        response = requests.post(f"{API}{path}", headers=HEADERS, json=body, timeout=120)

        # 429 is throttled and 503 is "not yet": nothing ran, so wait and send it again.
        if response.status_code not in (429, 503):
            return response

        time.sleep(int(response.headers.get("Retry-After", "5")))


task_ids = []

for start in range(0, len(TARGETS), PER_LAUNCH):
    chunk = TARGETS[start:start + PER_LAUNCH]
    response = post("/faceswap/tasks", {
        "faceImageUrl": FACE,
        "targetFiles": [{"url": url} for url in chunk],
    })
    launch = response.json()

    # Refused before it created anything: nothing runs and nothing is held.
    if not response.ok:
        print(response.status_code, launch["code"], launch.get("detail"))
        continue

    # A launch refused part-way still lists the tasks it created.
    task_ids += [task["taskId"] for task in launch["data"]]

    if launch["refusal"]:
        print(launch["refusal"]["code"], launch["refusal"].get("detail"))

tasks = [wait_for(task_id) for task_id in task_ids]
```

**Types for TypeScript, from the OpenAPI document**

```bash
npx openapi-typescript https://faceswap.ing/api/v1/openapi.json -o faceswap-api.d.ts
```

## API guide

The same swaps and upscales the app runs, called from your own code. A key spends the swapz of the team it belongs to, sees that team's faces, generations and upscales, and nothing else.

### Overview

Every route lives under https://faceswap.ing/api/v1. Requests and successful responses are JSON, and every error is an application/problem+json document, which the next section spells out. Send the key in an x-api-key header on every request: there is no other way to authenticate and no session to establish.

Keys are made under Settings, API keys. A personal key starts with fsper_ and belongs to the person who made it. An organization key starts with fsorg_, is made by an owner and belongs to the workspace, so any owner can revoke it. Both name one team when they are made, and that is the team whose swapz they spend and whose faces, generations and upscales they see. Either kind stops working when the account that made it is deleted.

The full key is shown once, when it is created. Only a hash and the first characters are stored, so a lost key is replaced rather than recovered.

The account holder has to confirm once, in the app, that they are 18 or older and that everyone whose face they upload has agreed to it. Until that is done every launch is refused with declaration_required, whichever key made it. Opening the app and starting a swap confirms it, and every key on the account works immediately afterwards.

Times come back as Postgres timestamps with a zone, such as 2026-09-08 09:43:07.375+00. Ids are uuids. Work is paid for in swapz, and what a call costs is set out under Prices below.

Every route below is described as an OpenAPI 3.1 document at https://faceswap.ing/api/v1/openapi.json. It is generated from the models the routes validate with, so it is the place to point a client generator, and it needs no key to read.

### Errors

Every error is an application/problem+json document (RFC 9457): { "type": "about:blank", "title": "Payment Required", "status": 402, "code": "insufficient_swapz", "detail": "..." }. code is a stable snake_case word, and it is the field to branch on. detail is an English sentence of ours about this occurrence, for a person or a log: it may be reworded, some refusals leave it out, and it is never the processing pipeline's own message. title is the status's own phrase, and status repeats the HTTP status. A refusal about numbers or a file adds shortfall, processing or sourceUrl, and members may be added later, so ignore one you do not know.

401 is a key the request cannot act with: code api_key_missing when the x-api-key header is missing, and api_key_invalid when the key does not verify. A key whose account was deleted, whose team was never written, or that has been revoked answers api_key_invalid too.

403 with code organization_suspended: the workspace the key belongs to is suspended, and no call it can make will succeed until that is lifted.

404 with code not_found answers a task or upscale id the key's team does not own. One from another workspace is not distinguishable from one that never existed.

429 with code rate_limited and a Retry-After header: the key has made too many requests in the current window. Rate limits below says how many that is.

422 with code validation is a request that does not match the route's model: { "type": "validation", "code": "validation", "title": "Validation Error", "status": 422, "on": "body", "property": "/name" }. "on" says which part of the request failed, body, query or params, and "property" is a JSON pointer to the first field that did not match. The body does not say what was wrong with it or echo what was sent: check the request against the route's model in the OpenAPI document. A body that is not JSON at all answers 400 with code parse.

A launch refused before it created anything answers the refusal's own status, a swap's and an upscale's alike, and nothing was held. 400 is a file or a queue the launch cannot take as asked, and the same request will be refused again. 402 is insufficient_swapz, with shortfall. 403 is declaration_required or feature_not_allowed, which only somebody with the account can clear. 502 is launch_failed or unpriced_queue, a fault on our side. 503 is moderation_unavailable, media_checking or service_busy, which clear by themselves, and carries Retry-After: 30: send the same request again after that many seconds.

A swap launch can also be refused part-way, after it created some of its tasks. That answers 200: "data" holds the tasks created before it stopped, which run and are charged as usual, and "refusal" is the problem document saying why the rest did not launch. A launch that ran whole answers "refusal": null. Read refusal on every 200 rather than assuming the whole queue launched.

code is declaration_required when the account holder has not made the declaration described above. A caller clears it without changing the request, by making the declaration once.

code is moderation_unavailable when a file you named could not be screened: the screening service did not answer in time, or could not read the URL. Nothing was priced, held or started, and the file was not judged. It is retryable: send the same request again after the Retry-After seconds, and back off if it repeats.

code is moderation_refused when the screening looked at a file and refused it. The same file will be refused again, so do not retry it.

code is media_not_in_library when a URL you named is on our own file host and is not your team's: not a file in the team's library, not the outputUrl of a completed swap or upscale of the team's, and not a sample named as a target. A sample is one of the photos and videos the site offers every account to try a swap on; it can only be a target. Nothing was held, and the same request will be refused again. A file in the team's library that is still being checked answers media_checking instead, which clears by itself: send the same request again after the Retry-After seconds. One that was set aside unchecked answers media_held, and one that was never checked answers media_unjudged: upload it again in the app.

shortfall is { "available": 12, "needed": 40 } and comes with a detail naming both numbers: the queue was priced, and the team does not hold enough swapz for it. Buy swapz and send the same request again.

code is file_over_processing_cap when one file needs more processing time than a single file is allowed. It comes with processing, { "capMinutes": 90, "estimatedMinutes": 112 }, and nothing was held. The same request will be refused again: trim the file, send it with a lower maxMegapixels, or turn an option off.

code is gif_unavailable when a target file is a GIF and the running deployment does not swap GIFs. gif_too_long is a GIF that runs longer than a GIF of its size may, and gif_too_large is one whose frame is over the limit. Nothing was held in any of the three, and the same request will be refused again.

code is video_unmeasured when a target is a video in the workspace's library with no recorded length, so it cannot be priced. A durationSeconds in the request does not stand in for it. Upload the file again, or send a trim; nothing was held.

code is insufficient_swapz when the team does not hold what the queue costs, feature_unavailable when the request asks for something the running deployment does not offer, feature_not_allowed when it asks for an option this workspace may not use, and face_unavailable when a saved face named in a cast has no usable photo. detail names the option or the numbers.

code is service_busy when the queue is full: nothing was held, and the same request may be sent again after the Retry-After seconds. launch_failed is a fault on our side: nothing was held, and it has been logged.

A file or a queue that breaks a bound is refused with the bound's own code and nothing held: file_too_large, image_too_large, video_too_large, video_too_long, output_too_large, task_too_large, task_too_long, frame_too_large, requested_size_too_large, enhancer_input_too_large, face_position_out_of_range, missing_face_source, trim_on_image, unsupported_media_type or feature_disabled. quote_refused is a queue that cannot run as it stands for a reason with no narrower code. Treat a code that is not listed here as a refusal that will repeat.

A task that was launched and then failed is not a refusal: it is read from the task. A failed file carries errorCode and error, and so does a task that failed or was closed. errorCode is the stable field: download_failed, unreadable_input, file_too_large, video_too_long, video_too_large, image_too_large, output_too_large, gif_too_long, gif_too_large, gif_not_animated, task_too_large, task_too_long, task_timeout, not_processed, gpu_failure, no_face_in_source, trim_out_of_range, frame_out_of_range, content_rejected, interrupted, no_output, processing_failed, watermark_unavailable, upload_failed, partial_failure, all_files_failed, cancelled, worker_inactive, scheduling_failed, stalled. error is one fixed English sentence of ours per code, and a code with no sentence of its own reads "Something went wrong. The error code, if any, says what.". Both are null on a file or a task that did not fail. Until 2026-10-06 error carried the processing pipeline's own message; it no longer does, so do not match on its text.

An upscale launch is refused by status the same way. The codes of its own, and when sourceUrl names a photo, are listed under Upscales below.

A launch sent with an Idempotency-Key header has two refusals of its own, both before anything is launched. 409 with code idempotency_key_in_progress and a Retry-After header: the first request with this key has not answered yet; wait and send the same request again. 422 with code idempotency_key_reused: the key was sent before with a different body, and a different launch needs a new key. A header value that is not 1 to 255 printable ASCII characters is a validation failure like any other, with "on": "headers".

**A refused launch**

```
HTTP/1.1 402 Payment Required
content-type: application/problem+json

{
  "type": "about:blank",
  "title": "Payment Required",
  "status": 402,
  "code": "insufficient_swapz",
  "detail": "This queue costs 40 swapz and the team has 12.",
  "shortfall": { "available": 12, "needed": 40 }
}
```

### Rate limits

Limits are per key, and reads and launches are counted apart. A key may make 120 read requests every 60 seconds across the GET routes, and 30 launches every 60 seconds on POST /faceswap/tasks and POST /upscales together. One swap launch may carry up to 200 files and one upscale up to 50 photos, so the launch limit bounds requests, not files.

Windows are fixed and aligned to the clock: each 60 seconds starts a fresh count. A request past the limit is answered 429 with code rate_limited and a Retry-After header holding the seconds left in the window. Wait that long and send the same request again; a refused request costs nothing and is not counted.

A key that has used up its launches can still read, and the other way round. Two keys never share a counter, even on one team. A request with a missing or invalid key is refused before anything is counted.

A webhook is the way to stay far under the read limit: register one on the key and the task's result is sent to you when it ends, in place of a poll.

### Pagination

Every list is cursor paged. A page carries data, pageSize, and nextCursor, which is null on the last page.

Ask for the next page by sending the cursor you were given back as the cursor query parameter. There is no page number and no total: a cursor names the last row you saw, so rows written while you page do not shift what comes next.

Rows come newest first, ordered on the creation time and then the id, so two rows written in the same instant still have one order.

### Faces

A face is a name and up to 5 photos of one person, saved in the app. The API reads them; it does not create them, because a face is built from photos already in the team's library and the key surface has no upload route.

imageUrl is the first photo, which is the primary one. imageUrls holds all of them in order. A face whose photos were all removed comes back with an empty list and a null imageUrl, which is what the with_image filter is for.

#### List faces

`GET /faces`

The team's saved faces, newest first. userId is whoever saved the face; ownership is the team, so a key sees every face in its team rather than only the ones its creator saved.

Query:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | string | No | Where the previous page ended, copied from its nextCursor. Omit it for the first page. The value is opaque and its format is ours to change, so store it rather than parsing it. |
| `pageSize` | integer | No | How many rows to return, from 1 to 100. Defaults to 20. A value that is not a number falls back to the default rather than failing the request. |
| `search` | string | No | Match on the face's name, case-insensitively, anywhere in it. Trimmed, and at most 255 characters. |
| `status` | "with_image" | No | The only filter the list takes. Send with_image to return only faces that still have at least one photo. Anything else is ignored rather than refused. |

**Request, curl**

```bash
curl "https://faceswap.ing/api/v1/faces?pageSize=2&search=ada" \
  -H "x-api-key: fsper_YOUR_KEY"
```

**Request, JavaScript**

```js
const response = await fetch("https://faceswap.ing/api/v1/faces?pageSize=2&search=ada", {
  headers: { "x-api-key": "fsper_YOUR_KEY" },
});

console.log(await response.json());
```

**Request, Python**

```python
import requests

response = requests.get(
    "https://faceswap.ing/api/v1/faces?pageSize=2&search=ada",
    headers={"x-api-key": "fsper_YOUR_KEY"},
)

print(response.json())
```

**Response**

```json
{
  "data": [
    {
      "createdAt": "2026-09-08 09:43:07.379+00",
      "id": "01a08066-5d73-7000-add1-fb5a632aede1",
      "imageUrl": "https://cdn.faceswap.ing/permanent/faces/ada-1.jpg",
      "imageUrls": [
        "https://cdn.faceswap.ing/permanent/faces/ada-1.jpg",
        "https://cdn.faceswap.ing/permanent/faces/ada-2.jpg"
      ],
      "name": "Ada",
      "updatedAt": "2026-09-08 09:43:07.379+00",
      "userId": "01a08066-5d66-7000-9a86-605f95602406"
    }
  ],
  "nextCursor": null,
  "pageSize": 2
}
```

### Swaps

One launch can become more than one task. The queue is divided by the things the service takes per task, the lip-sync track and the frame size, and then by the processing budget, so a long queue comes back as several tasks that run in parallel. Read the whole data array rather than its first entry.

targetFiles[].url is the photo or video the face goes onto, and is the only field a file must have. targetFiles[].facePosition picks which face in the target is replaced, counting from zero left to right; omitted, it is the first face. targetFiles[].faceSelection is reference, one or many: reference replaces the face at facePosition, one replaces the first face from the left, and many replaces every face in the target. Omitted, it is reference. targetFiles[].referenceFrameNumber says which frame those positions were read from.

targetFiles[].swaps puts several people in one target, each its own pass, up to 4 per file. Each entry takes a faceId from the face list, a facePosition saying which face it replaces, and an optional faceSelection. Two entries cannot claim the same position, many only works when the cast has one person in it, and a file that names swaps must not also name its own facePosition.

targetFiles[].trim takes startSeconds and endSeconds and processes only that segment, which is the largest lever there is on what a video costs. It has to end after it starts, and only a video can be trimmed. targetFiles[].lipSync takes an audioUrl and an optional weight from 0 to 1 and drives the mouth from that track; only a video can be lip synced, and a queue carrying two tracks becomes two tasks.

targetFiles[].maxMegapixels processes the file at no more than that many megapixels, downscaled once before the swap; it is what lets a 4K upload bill at the 1080p rate, and it is capped at 8.9. targetFiles[].durationSeconds is how long you read the video to be, used only as a fallback when the probe cannot say. targetFiles[].thumbnailUrl is a still to show the file by, and is never processed.

processors.ageModifier takes ageShift, a whole number from -100 to 100, and is the one option with no default: a shift of nothing is not an age modification. processors.expressionRestorer takes strength from 0 to 1 and carries the target's own expression back onto the new face. processors.backgroundRemover takes fillColor as four channels from 0 to 255, red, green, blue and alpha, and defaults to transparent, which needs a PNG target to survive.

processors.frameEnhancer takes scale, 2 or 4, and blend from 0 to 1. processors.frameColorizer takes blend from 0 to 1. processors.faceEditor moves the face itself along fourteen axes, each from -1 to 1 with 0 leaving it alone: eyebrowDirection, eyeGazeHorizontal, eyeGazeVertical, eyeOpenRatio, headPitch, headRoll, headYaw, lipOpenRatio, mouthGrim, mouthPositionHorizontal, mouthPositionVertical, mouthPout, mouthPurse and mouthSmile. processors.watermark is a boolean.

Every processor except watermark is an object, and its presence is what turns it on: an empty object runs it with the service's defaults. Omit the whole processors field to run a plain swap.

A task is queued, then processing, then one of four terminal statuses: completed, when every file finished; partially_completed, when some did and some did not; failed; and cancelled. cancelling is not terminal, it means the cancellation is in and the worker is finishing the file it is on. Statuses are applied through a transition table, so an event that arrives late or twice never moves a task backwards and a terminal status is final.

Each file in a task carries its own status: pending, processing, completed, failed, insufficient_coins for a file nobody could pay for, and system_cancelled, system_maintenance or user_cancelled for the three ways one is cancelled. cost is what that generation was charged in swapz, and it is set when the file completes.

A failed file is refunded. What was held for the task is reconciled against what the files actually cost once, and the difference goes back to the team's permanent balance.

#### Launch a swap

`POST /faceswap/tasks`

Prices the queue, holds the swapz, and dispatches. The response carries the tasks it created, each already holding its files. A launch refused before it created anything answers the refusal's status with a problem document, and one refused part-way answers 200 with the refusal beside the tasks it created, both described under Errors. Send an Idempotency-Key with every launch so that a retry after a lost answer cannot launch twice.

Headers:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | string | No | A value of your own for this launch, such as a UUID, sent again unchanged with every retry of it. Within 24 hours of the first request, a retry with the same key and the same body from the same API key is answered with the first launch's response and the header Idempotent-Replayed: true, and launches and holds nothing. Only a launch that created tasks is stored: one refused before anything was held gives the key back, so the same request with the same key runs when it is sent again. 1 to 255 printable ASCII characters. |

Body:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `faceEnhancer` | boolean | No | Turn the face enhancer on or off. It is on by default at the service, so send false to turn it off; omitting the field leaves it on. |
| `faceId` | string | No | The saved face these photos came from, recorded on the task so a generation can be traced back to the library. It does not fetch the photos; faceImageUrl does. |
| `faceImageUrl` | string (url) | Yes | The face to put on. A public HTTPS URL to a photo of one person. A URL you host is fetched and screened as the swap is launched; a URL on our file host has to be the team's own file. |
| `faceImageUrls` | string[] (url) | No | More photos of the same person, whose embeddings are averaged into one identity. Up to 4 beside faceImageUrl, 5 in all. More angles of one person is the cheapest quality there is. |
| `metadata` | object | No | Your own bag of keys and values. It is stored on the task and passed through to the inference service as it arrived; nothing here reads it, and it is not part of what a task read gives back. |
| `preset` | "balanced" \| "best" \| "fast" | No | Quality and cost profile: balanced, best or fast. Defaults to balanced. best adds 60% to the base; fast is priced as balanced. The full rate card is at https://faceswap.ing/pricing. |
| `processors` | object | No | The options to run beside the swap, as objects. Present means on, an empty object means on with the service's defaults, and each is priced on top of the base. The fields of each are described above. |
| `targetFiles` | object[] | Yes | The photos and videos to swap onto, from 1 to 200 of them. Each entry needs a url and takes the per-file fields described above. |

**Request, curl**

```bash
curl -X POST "https://faceswap.ing/api/v1/faceswap/tasks" \
  -H "x-api-key: fsper_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "faceImageUrl": "https://cdn.faceswap.ing/permanent/faces/ada-1.jpg",
    "preset": "balanced",
    "processors": {
      "faceEditor": {
        "mouthSmile": 0.2
      }
    },
    "targetFiles": [
      {
        "url": "https://cdn.faceswap.ing/permanent/uploads/scene.jpg"
      }
    ]
  }'
```

**Request, JavaScript**

```js
const response = await fetch("https://faceswap.ing/api/v1/faceswap/tasks", {
  method: "POST",
  headers: {
    "x-api-key": "fsper_YOUR_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "faceImageUrl": "https://cdn.faceswap.ing/permanent/faces/ada-1.jpg",
    "preset": "balanced",
    "processors": {
      "faceEditor": {
        "mouthSmile": 0.2
      }
    },
    "targetFiles": [
      {
        "url": "https://cdn.faceswap.ing/permanent/uploads/scene.jpg"
      }
    ]
  }),
});

console.log(await response.json());
```

**Request, Python**

```python
import requests

response = requests.post(
    "https://faceswap.ing/api/v1/faceswap/tasks",
    headers={"x-api-key": "fsper_YOUR_KEY"},
    json={
        "faceImageUrl": "https://cdn.faceswap.ing/permanent/faces/ada-1.jpg",
        "preset": "balanced",
        "processors": {
            "faceEditor": {
                "mouthSmile": 0.2,
            },
        },
        "targetFiles": [
            {
                "url": "https://cdn.faceswap.ing/permanent/uploads/scene.jpg",
            },
        ],
    },
)

print(response.json())
```

**Response**

```json
{
  "data": [
    {
      "taskId": "01a08066-5d70-7000-a3db-ff3ef6b651ca",
      "userId": "01a08066-5d66-7000-9a86-605f95602406",
      "status": "queued",
      "queuePosition": 2,
      "faceImageUrl": "https://cdn.faceswap.ing/permanent/faces/ada-1.jpg",
      "faceId": null,
      "progress": { "current": 0, "total": 1, "percentage": 0, "stage": "queued" },
      "startedAt": null,
      "completedAt": null,
      "createdAt": "2026-09-08 09:43:07.375+00",
      "updatedAt": "2026-09-08 09:43:07.375+00",
      "purgedAt": null,
      "error": null,
      "errorCode": null,
      "files": [
        {
          "id": "01a08066-5d70-7fff-9e92-493cc98e0da6",
          "status": "pending",
          "fileType": "image",
          "targetUrl": "https://cdn.faceswap.ing/permanent/uploads/scene.jpg",
          "targetThumbnailUrl": null,
          "outputUrl": null,
          "outputThumbnailUrl": null,
          "lipSyncAudioUrl": null,
          "progress": { "current": 0, "total": 100, "percentage": 0, "stage": "pending" },
          "startedAt": null,
          "completedAt": null,
          "createdAt": "2026-09-08 09:43:07.377+00",
          "updatedAt": "2026-09-08 09:43:07.377+00",
          "processingTime": null,
          "cost": null,
          "error": null,
          "errorCode": null
        }
      ]
    }
  ],
  "refusal": null
}
```

#### List swaps

`GET /faceswap/tasks`

The team's swaps, newest first, each with its files. Progress on a running task is refreshed before the page is read, so the percentages come back current. A row carries every field a launch answers with; the sample below is shortened to the ones a poller reads.

Query:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | string | No | Where the previous page ended, copied from its nextCursor. Omit it for the first page. The value is opaque and its format is ours to change, so store it rather than parsing it. |
| `pageSize` | integer | No | How many rows to return, from 1 to 100. Defaults to 10. A value that is not a number falls back to the default rather than failing the request. |
| `status` | task status, one or many | No | Keep only tasks in these statuses. Repeat the key or separate the values with commas. Unknown values are dropped, and a filter left with nothing valid returns everything. |

**Request, curl**

```bash
curl "https://faceswap.ing/api/v1/faceswap/tasks?pageSize=10&status=completed" \
  -H "x-api-key: fsper_YOUR_KEY"
```

**Request, JavaScript**

```js
const response = await fetch("https://faceswap.ing/api/v1/faceswap/tasks?pageSize=10&status=completed", {
  headers: { "x-api-key": "fsper_YOUR_KEY" },
});

console.log(await response.json());
```

**Request, Python**

```python
import requests

response = requests.get(
    "https://faceswap.ing/api/v1/faceswap/tasks?pageSize=10&status=completed",
    headers={"x-api-key": "fsper_YOUR_KEY"},
)

print(response.json())
```

**Response**

```json
{
  "data": [
    {
      "taskId": "01a08066-5d70-7000-a3db-ff3ef6b651ca",
      "status": "completed",
      "progress": { "current": 1, "total": 1, "percentage": 100, "stage": "completed" },
      "files": [
        {
          "id": "01a08066-5d70-7fff-9e92-493cc98e0da6",
          "status": "completed",
          "outputUrl": "https://cdn.faceswap.ing/permanent/generations/scene-swapped.jpg",
          "cost": 5
        }
      ]
    }
  ],
  "nextCursor": "MjAyNi0wOS0wOCAwOTo0MzowNy4zNzkrMDB8MDFhMDgwNjY",
  "pageSize": 10
}
```

#### Read one swap

`GET /faceswap/tasks/:taskId`

One task and its files, for polling. It reads the stored row and calls nothing on the way, so it is cheap to ask often, and it answers with one task rather than a page. A task the key's team does not own answers 404. The sample below is shortened the same way the list's is.

Path:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `taskId` | string (uuid) | Yes | The id of a task the key's team owns, as returned by a launch or by the list. |

**Request, curl**

```bash
curl "https://faceswap.ing/api/v1/faceswap/tasks/01a08066-5d70-7000-a3db-ff3ef6b651ca" \
  -H "x-api-key: fsper_YOUR_KEY"
```

**Request, JavaScript**

```js
const response = await fetch("https://faceswap.ing/api/v1/faceswap/tasks/01a08066-5d70-7000-a3db-ff3ef6b651ca", {
  headers: { "x-api-key": "fsper_YOUR_KEY" },
});

console.log(await response.json());
```

**Request, Python**

```python
import requests

response = requests.get(
    "https://faceswap.ing/api/v1/faceswap/tasks/01a08066-5d70-7000-a3db-ff3ef6b651ca",
    headers={"x-api-key": "fsper_YOUR_KEY"},
)

print(response.json())
```

**Response**

```json
{
  "taskId": "01a08066-5d70-7000-a3db-ff3ef6b651ca",
  "status": "completed",
  "progress": { "current": 1, "total": 1, "percentage": 100, "stage": "completed" },
  "startedAt": "2026-09-08 09:43:07.375+00",
  "completedAt": "2026-09-08 09:45:11.002+00",
  "purgedAt": null,
  "files": [
    {
      "id": "01a08066-5d70-7fff-9e92-493cc98e0da6",
      "status": "completed",
      "outputUrl": "https://cdn.faceswap.ing/permanent/generations/scene-swapped.jpg",
      "processingTime": 3,
      "cost": 5
    }
  ]
}
```

### Upscales

An upscale enlarges photos to 2K, 4K or 8K and rebuilds the detail a small or soft photo is missing. Each photo keeps its proportions. One launch takes from 1 to 50 photos, each at its own tier, and is always one upscale: unlike a swap, it is never divided. Upscales have routes and a webhook event of their own, so nothing about swaps changes for a client that does not use them.

files[].sourceUrl is the photo, and it can be one of three things. A public HTTPS URL of a photo you host is screened as the launch is made, the way a swap's files are. The outputUrl of a completed swap made by the key's own team is taken as it is, without a new screening, because it was made from files that already passed. A photo in the team's library, as the app uploaded it, is taken once its own check has passed, and refused with media_checking until then. Any other URL on our storage is refused with media_not_in_library, whoever it belongs to.

files[].tier is 2k, 4k or 8k. files[].outputFormat is jpg, png or webp, and jpg when it is left out. One photo may be asked for at two tiers in one launch, but not twice at the same tier. A photo can be JPG, PNG, WEBP, BMP, HEIC or HEIF, of up to 50 MB and 50 megapixels. A video, a GIF or a swap made from one is refused with unsupported_media, and a photo over 50 megapixels with image_too_large.

A tier is a size, not a factor. The output is the largest that fits inside the tier's bounds in the table below, and its long side is never over 8192 pixels. A tier that would not make each side at least 1.25 times longer is refused with tier_not_larger before anything is held: a photo already about as large as 4K is not sold a 4K upscale. When we hold no size for a photo, the service measures it as the launch is made and refuses it with the same codes, and what was held is given back.

Each photo costs its tier's price, in the table below, or less where the team has a rate of its own: that rate is applied to each photo and rounded up to a whole swapz. The total is held when the upscale launches. When it ends, completed photos keep their price and the rest is returned, so a photo that failed costs nothing.

An upscale goes through the same statuses as a swap task, queued, processing, then completed, partially_completed, failed or cancelled, and each photo through the same file statuses. A completed photo has its result in outputUrl, with outputWidth and outputHeight. outputUrl is null until the result has been copied to our storage.

A failed photo carries errorCode and error as a swap's failed file does, with the codes listed under Errors and one of its own: tier_not_larger, when the photo turned out to be too large for its tier to enlarge.

Results are deleted 30 days after the upscale ends, as a swap's are, and deletedAt says when. That holds even for a result that has since been used as the input of something else. The photos an upscale was made from are not deleted with it.

A refused upscale launch answers the refusal's own status, never 200, and holds nothing. The body is a problem document, as described under Errors: branch on code; detail is an English sentence of ours and may be reworded.

sourceUrl names the photo whenever we refuse one before the upscale is sent: tier_not_larger, image_too_large and unsupported_media for a size or kind we know, moderation_refused, moderation_unavailable, media_checking, and media_not_in_library or another library code. When several photos are refused for the same reason, it names the first. A photo we hold no size for is measured by the upscale service when the upscale is created, and if it refuses there, with tier_not_larger or image_too_large, the whole launch is refused without sourceUrl, because the service does not say which photo. insufficient_swapz carries shortfall instead, and a 502 or service_busy names no photo.

400 is a photo the upscale cannot take as asked: tier_not_larger, image_too_large, unsupported_media, moderation_refused, media_not_in_library, another library code, or another bound the service refuses on. The same request will be refused again. 402 is insufficient_swapz, with shortfall. 403 is declaration_required or feature_not_allowed. 502 is a fault on our side: launch_failed, or unpriced_queue for photos that could not be priced.

503 clears by itself: moderation_unavailable when a photo you host could not be screened just now, media_checking when a library photo is still being checked, and service_busy when the queue is full. A 503 carries Retry-After: 30. Send the same request again after that many seconds. An Idempotency-Key works as it does on a swap launch, with the same 409 and 422.

When an upscale ends, the key's webhook receives upscale.completed, described under Webhooks.

| Tier | Megapixels, at most | Short side, at most | Swapz a photo | USD |
| --- | --- | --- | --- | --- |
| 2k | 4.2 | 1440 px | 8 | $0.08 |
| 4k | 8.3 | 2160 px | 15 | $0.15 |
| 8k | 33.2 | 4320 px | 30 | $0.30 |

#### Launch an upscale

`POST /upscales`

Resolves and screens every photo, prices each at the team's rate, holds the total and dispatches. The answer is the upscale it created, in the shape GET /upscales/:upscaleId reads, with every photo pending and cost set to its price. A refusal is a problem document with the refusal's status, described above.

Headers:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | string | No | A value of your own for this launch, such as a UUID, sent again unchanged with every retry of it. Within 24 hours of the first request, a retry with the same key and the same body from the same API key is answered with the first launch's response and the header Idempotent-Replayed: true, and launches and holds nothing. Only a launch that created an upscale is stored: a refused one gives the key back. 1 to 255 printable ASCII characters. |

Body:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `files` | object[] | Yes | The photos, from 1 to 50. Each takes a sourceUrl and a tier, and an optional outputFormat, as described above. |
| `metadata` | object | No | Your own keys and values. Stored on the upscale as sent, and returned with it on every read and in its webhook event. Nothing of ours reads it. |

**Request, curl**

```bash
curl -X POST "https://faceswap.ing/api/v1/upscales" \
  -H "x-api-key: fsper_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "files": [
      {
        "sourceUrl": "https://cdn.faceswap.ing/permanent/generations/scene-swapped.jpg",
        "tier": "4k"
      },
      {
        "outputFormat": "png",
        "sourceUrl": "https://example.com/photos/portrait.jpg",
        "tier": "2k"
      }
    ],
    "metadata": {
      "order": "A-1042"
    }
  }'
```

**Request, JavaScript**

```js
const response = await fetch("https://faceswap.ing/api/v1/upscales", {
  method: "POST",
  headers: {
    "x-api-key": "fsper_YOUR_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "files": [
      {
        "sourceUrl": "https://cdn.faceswap.ing/permanent/generations/scene-swapped.jpg",
        "tier": "4k"
      },
      {
        "outputFormat": "png",
        "sourceUrl": "https://example.com/photos/portrait.jpg",
        "tier": "2k"
      }
    ],
    "metadata": {
      "order": "A-1042"
    }
  }),
});

console.log(await response.json());
```

**Request, Python**

```python
import requests

response = requests.post(
    "https://faceswap.ing/api/v1/upscales",
    headers={"x-api-key": "fsper_YOUR_KEY"},
    json={
        "files": [
            {
                "sourceUrl": "https://cdn.faceswap.ing/permanent/generations/scene-swapped.jpg",
                "tier": "4k",
            },
            {
                "outputFormat": "png",
                "sourceUrl": "https://example.com/photos/portrait.jpg",
                "tier": "2k",
            },
        ],
        "metadata": {
            "order": "A-1042",
        },
    },
)

print(response.json())
```

**Response**

```json
{
  "completedAt": null,
  "createdAt": "2026-11-10 09:43:07.375+00",
  "deletedAt": null,
  "errorCode": null,
  "files": [
    {
      "cost": 15,
      "errorCode": null,
      "id": "01a6c2f4-3b12-7000-8d55-1c9e0f4a7b21",
      "outputFormat": "jpg",
      "outputHeight": 2160,
      "outputUrl": null,
      "outputWidth": 2880,
      "sourceUrl": "https://cdn.faceswap.ing/permanent/generations/scene-swapped.jpg",
      "status": "pending",
      "tier": "4k"
    },
    {
      "cost": 8,
      "errorCode": null,
      "id": "01a6c2f4-3b12-7001-a0e3-5f7d2c8b9e44",
      "outputFormat": "png",
      "outputHeight": null,
      "outputUrl": null,
      "outputWidth": null,
      "sourceUrl": "https://example.com/photos/portrait.jpg",
      "status": "pending",
      "tier": "2k"
    }
  ],
  "id": "01a6c2f4-3b10-7000-9c41-7d2e8a5b6f03",
  "metadata": { "order": "A-1042" },
  "reservedSwapz": 23,
  "settledAt": null,
  "status": "queued"
}
```

#### List upscales

`GET /upscales`

The team's upscales, newest first, each with its photos. One whose results have been deleted is still listed, with deletedAt set. The sample below is shortened to the fields a poller reads.

Query:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | string | No | Where the previous page ended, copied from its nextCursor. Omit it for the first page. The value is opaque and its format is ours to change, so store it rather than parsing it. |
| `pageSize` | integer | No | How many rows to return, from 1 to 50. Defaults to 10. A value that is not a number falls back to the default rather than failing the request. |

**Request, curl**

```bash
curl "https://faceswap.ing/api/v1/upscales?pageSize=10" \
  -H "x-api-key: fsper_YOUR_KEY"
```

**Request, JavaScript**

```js
const response = await fetch("https://faceswap.ing/api/v1/upscales?pageSize=10", {
  headers: { "x-api-key": "fsper_YOUR_KEY" },
});

console.log(await response.json());
```

**Request, Python**

```python
import requests

response = requests.get(
    "https://faceswap.ing/api/v1/upscales?pageSize=10",
    headers={"x-api-key": "fsper_YOUR_KEY"},
)

print(response.json())
```

**Response**

```json
{
  "data": [
    {
      "id": "01a6c2f4-3b10-7000-9c41-7d2e8a5b6f03",
      "status": "completed",
      "files": [
        {
          "id": "01a6c2f4-3b12-7000-8d55-1c9e0f4a7b21",
          "status": "completed",
          "tier": "4k",
          "outputUrl": "https://cdn.faceswap.ing/permanent/upscales/scene-swapped-4k.jpg",
          "cost": 15
        }
      ]
    }
  ],
  "nextCursor": null,
  "pageSize": 10
}
```

#### Read one upscale

`GET /upscales/:upscaleId`

One upscale and its photos, for polling until status is terminal. An upscale the key's team does not own answers 404. The sample below is shortened the same way the list's is.

Path:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `upscaleId` | string (uuid) | Yes | The id of an upscale the key's team owns, as returned by a launch or by the list. |

**Request, curl**

```bash
curl "https://faceswap.ing/api/v1/upscales/01a6c2f4-3b10-7000-9c41-7d2e8a5b6f03" \
  -H "x-api-key: fsper_YOUR_KEY"
```

**Request, JavaScript**

```js
const response = await fetch("https://faceswap.ing/api/v1/upscales/01a6c2f4-3b10-7000-9c41-7d2e8a5b6f03", {
  headers: { "x-api-key": "fsper_YOUR_KEY" },
});

console.log(await response.json());
```

**Request, Python**

```python
import requests

response = requests.get(
    "https://faceswap.ing/api/v1/upscales/01a6c2f4-3b10-7000-9c41-7d2e8a5b6f03",
    headers={"x-api-key": "fsper_YOUR_KEY"},
)

print(response.json())
```

**Response**

```json
{
  "id": "01a6c2f4-3b10-7000-9c41-7d2e8a5b6f03",
  "status": "completed",
  "completedAt": "2026-11-10 09:43:41.902+00",
  "settledAt": "2026-11-10 09:43:42.118+00",
  "deletedAt": null,
  "reservedSwapz": 15,
  "files": [
    {
      "id": "01a6c2f4-3b12-7000-8d55-1c9e0f4a7b21",
      "status": "completed",
      "tier": "4k",
      "outputUrl": "https://cdn.faceswap.ing/permanent/upscales/scene-swapped-4k.jpg",
      "outputWidth": 2880,
      "outputHeight": 2160,
      "cost": 15
    }
  ]
}
```

### Webhooks

A key can have one webhook: an HTTPS URL we POST to when a task or an upscale launched with that key ends. Register it under Settings, API keys, in the key's menu. The URL belongs to the key and is set by a signed-in person; a launch cannot name one, and there is no callback field on a task.

The URL has to be HTTPS on the default port, with no username or password in it, at an address reachable from the internet. That is checked when it is saved and again before every delivery, and a redirect is never followed: answer on the URL you registered.

Saving the URL shows a signing secret, starting with whsec_, once. It is stored encrypted and is not shown again. Saving the URL again makes a new secret and the old one stops signing at once, which is how a secret is rotated.

An event is sent when the task reaches a terminal status and its charge is final. type is one of task.completed, task.partially_completed, task.failed or task.cancelled. A task settles once, so it produces one event. An upscale sends upscale.completed when it ends, whatever it came to: data.upscale.status says how. The Send test event button in settings sends webhook.test, signed the same way.

The body is { id, type, timestamp, data }. timestamp is when the event was queued, in ISO 8601 such as 2026-09-08T09:45:11.120Z, unlike the Postgres timestamps the routes answer with.

data.task is exactly what GET /faceswap/tasks/:taskId answers for that task, so there is one shape to learn. That includes a failure: errorCode on the task and on each failed file is the code to branch on, and error is our fixed English sentence for it, never the processing pipeline's own message. data.chargedSwapz is what the task cost in the end: files that completed keep their price, every other file is refunded, and its cost reads 0. For an upscale, data.upscale is what GET /upscales/:upscaleId answers, and data.chargedSwapz is read the same way.

An event is held until the output of every completed file has been copied to our storage, so outputUrl is filled in when it arrives. That wait is capped at 30 minutes; past it the event is sent anyway and a missing outputUrl is null, to be read from the task later.

Deliveries follow Standard Webhooks (standardwebhooks.com) and carry its three headers. webhook-id is the event id, the same as id in the body and the same on every retry: deduplicate on it. webhook-timestamp is the second the attempt was signed, as seconds since the epoch. webhook-signature is v1, a comma and the base64 HMAC-SHA256 of the id, a full stop, the timestamp, a full stop and the raw request body. The HMAC is keyed with the bytes your signing secret encodes: the base64 after whsec_, decoded, not the text of the secret. The header may hold several signatures separated by spaces, and a delivery is ours when any one of them matches.

Any Standard Webhooks library verifies a delivery with the secret exactly as it is shown: standardwebhooks for JavaScript and Python, for one, or Svix's libraries, which follow the same scheme. To verify by hand, as the samples below do, compute that HMAC over the bytes you received, before parsing them, and compare it in constant time. Refuse a delivery whose timestamp is more than 5 minutes from your clock: the timestamp is inside what is signed, so a replayed request cannot carry a fresh one.

Answer with any 2xx status within 10 seconds. Anything else is a failure, a 3xx included, and so is no answer in time. The response body is ignored. A failed delivery is retried after 1 min, 5 min, 30 min, 2 h, 8 h, which is 6 attempts in all; the queue is worked every thirty minutes, so each wait is a minimum. Delivery is at least once: an answer that arrives just after the timeout is followed by a retry with the same id.

After 10 failed attempts in a row the endpoint is switched off. Settings says so on the key and offers to turn it back on. While it is off nothing is queued, and events from that time are not sent afterwards: read those tasks from the API. One delivery that lands puts the count back to zero.

Nothing is sent for a key that could not make a request itself: one that is disabled, expired or revoked, or whose workspace is suspended. Revoking a key removes its webhook with it.

**An event**

```
POST /your/webhook
webhook-id: 01a10c1e-7d2a-7000-8f4e-2b9f6f0c1a55
webhook-timestamp: 1788860711
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{
  "id": "01a10c1e-7d2a-7000-8f4e-2b9f6f0c1a55",
  "type": "task.completed",
  "timestamp": "2026-09-08T09:45:11.120Z",
  "data": {
    "chargedSwapz": 5,
    "task": {
      "taskId": "01a08066-5d70-7000-a3db-ff3ef6b651ca",
      "status": "completed",
      "files": [
        {
          "id": "01a08066-5d70-7fff-9e92-493cc98e0da6",
          "status": "completed",
          "outputUrl": "https://cdn.faceswap.ing/permanent/generations/scene-swapped.jpg",
          "cost": 5
        }
      ]
    }
  }
}
```

**A failed file in an event**

```
{
  "id": "01a10c1e-7d2a-7000-8f4e-2b9f6f0c1a56",
  "type": "task.failed",
  "timestamp": "2026-09-08T09:45:11.120Z",
  "data": {
    "chargedSwapz": 0,
    "task": {
      "taskId": "01a08066-5d70-7000-a3db-ff3ef6b651ca",
      "status": "failed",
      "errorCode": "all_files_failed",
      "error": "Every file in this task failed.",
      "files": [
        {
          "id": "01a08066-5d70-7fff-9e92-493cc98e0da6",
          "status": "failed",
          "outputUrl": null,
          "cost": 0,
          "errorCode": "no_face_in_source",
          "error": "No face was found in the face image."
        }
      ]
    }
  }
}
```

**An upscale event**

```
{
  "id": "01a6c2f4-4e01-7000-b7a2-93f0d6c1e855",
  "type": "upscale.completed",
  "timestamp": "2026-11-10T09:43:42.130Z",
  "data": {
    "chargedSwapz": 15,
    "upscale": {
      "id": "01a6c2f4-3b10-7000-9c41-7d2e8a5b6f03",
      "status": "completed",
      "metadata": { "order": "A-1042" },
      "files": [
        {
          "id": "01a6c2f4-3b12-7000-8d55-1c9e0f4a7b21",
          "status": "completed",
          "tier": "4k",
          "outputUrl": "https://cdn.faceswap.ing/permanent/upscales/scene-swapped-4k.jpg",
          "cost": 15
        }
      ]
    }
  }
}
```

**Verify in Node**

```
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody is the request body exactly as received, before any JSON parsing.
// secret is the signing secret as shown, whsec_...
export function verify(rawBody, headers, secret) {
  const id = headers["webhook-id"] ?? "";
  const timestamp = Number(headers["webhook-timestamp"]);
  const signatures = (headers["webhook-signature"] ?? "").split(" ");

  // Refuse a delivery older than 5 minutes: a replay carries an old timestamp.
  if (!(Math.abs(Date.now() / 1000 - timestamp) <= 300)) return false;

  // The key is the bytes the secret encodes, after its prefix.
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = Buffer.from(
    createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64"),
  );

  // The header may list more than one signature, as "v1,<base64>" each.
  return signatures.some((one) => {
    const [version, value = ""] = one.split(",");
    const given = Buffer.from(value);

    return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

**Verify in Python**

```
import base64
import hashlib
import hmac
import time

# raw_body is the request body exactly as received, as bytes.
# secret is the signing secret as shown, whsec_...
def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
    webhook_id = headers.get("webhook-id", "")
    timestamp = headers.get("webhook-timestamp", "")
    signatures = headers.get("webhook-signature", "").split(" ")

    # Refuse a delivery older than 5 minutes: a replay carries an old timestamp.
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False

    # The key is the bytes the secret encodes, after its prefix.
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = webhook_id.encode() + b"." + timestamp.encode() + b"." + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    # The header may list more than one signature, as "v1,<base64>" each.
    return any(
        version == "v1" and hmac.compare_digest(value, expected)
        for version, _, value in (one.partition(",") for one in signatures)
    )
```

### Prices

A call is priced by what it processes, in swapz. One swapz is $0.01, so every price below is also a price in dollars. The GET routes cost nothing. A launch costs what its files cost and nothing on top: there is no fee per request.

A photo costs 5 swapz ($0.05), or 3 swapz ($0.03) each when the task holds 20 photos or more. A video is priced per second of footage after any trim, by the size of its frames, and is billed for at least 10 seconds, so the shortest clip costs 30 swapz ($0.30).

These are the base prices on the balanced and fast presets. The best preset adds 60%, and each processor adds its own line. The full card, with every option, is at https://faceswap.ing/pricing, and it is the same card the app charges from.

The price of a queue is held from the key's team when the task is launched. When the task ends, files that completed keep their price and everything else is returned, so a failed file costs nothing. cost on each file and chargedSwapz on the webhook say what was kept.

Listing the faces in a photo, which the app's face picker does, costs 1 swapz ($0.01) there. This surface has no detection route, so a key is never charged for it.

An upscale costs 8 swapz ($0.08) a photo at 2K, 15 swapz ($0.15) a photo at 4K and 30 swapz ($0.30) a photo at 8K, whatever the photo's size, with no preset and no options. The same hold and return apply, photo by photo.

| What is processed | Swapz | USD |
| --- | --- | --- |
| One photo | 5 | $0.05 |
| One photo, in a task of 20 or more | 3 | $0.03 |
| Video at 480p, up to 0.6 megapixels a frame, per second | 3 | $0.03 |
| Video at 720p, over 0.6 and up to 1 megapixels a frame, per second | 4 | $0.04 |
| Video at 1080p, over 1 and up to 2.3 megapixels a frame, per second | 6 | $0.06 |
| Video at 1440p, over 2.3 and up to 4 megapixels a frame, per second | 9 | $0.09 |
| Video at 4K, over 4 and up to 8.9 megapixels a frame, per second | 15 | $0.15 |
| An upscale to 2K, per photo | 8 | $0.08 |
| An upscale to 4K, per photo | 15 | $0.15 |
| An upscale to 8K, per photo | 30 | $0.30 |

### Media

There is no upload route on this surface. Every file you name is a public HTTPS URL that the service fetches for itself, so it has to be reachable without a header of yours for as long as the swap runs.

Each image and video you host is screened as the swap is launched, before anything is priced or dispatched: the target files, the face photos and the thumbnail. A file the screening refuses refuses the whole launch, with code moderation_refused. A file the screening could not check also refuses the whole launch, with code moderation_unavailable: no file is swapped unchecked, so an outage of the check delays a launch instead of letting it through. That refusal holds nothing and is safe to retry. A lip-sync track is audio, which the screening cannot judge, so it is fetched as it is.

A URL on our own file host is never screened into use. It is taken as it is when it is your team's: a file in the team's library, the outputUrl of a completed swap or upscale of the team's, the thumbnail of such a swap as a thumbnail, or, as a target, a sample (one of the photos and videos the site offers to try a swap on). Any other URL of ours, another team's file or output included, refuses the whole launch with code media_not_in_library before anything is priced or held. A lip-sync track or a thumbnail on our host is held to the same rule.

A photo may be up to 50 MB and 50 megapixels. A video may be up to 1 GB, 8.9 megapixels a frame and 60 frames a second. With the frame enhancer on, the upscaled output may be at most 8192 pixels on its long side.

How long a video may run depends on its frame, measured after any trim: 300 seconds up to 1440p, 270 seconds up to 4K. Those lengths hold at up to 30 frames a second on the balanced preset, with one pair and no processors. The bound behind them is processing time: a file whose estimated processing is over 90 minutes is refused at launch with code file_over_processing_cap, before anything is held, so a higher frame rate, the best preset, extra pairs and processors all shorten what fits.

A target whose URL ends in .gif is swapped frame by frame and comes back as a GIF. It is billed per second like a video, at the tier of its frame, with the same 10-second minimum. A GIF may be up to 100 MB, 30 seconds and 1 megapixel a frame, and its frames may add up to 150 megapixels in all: 480x270 runs the full 30 seconds at 30 frames a second, 1280x720 runs 5.4. A faster GIF comes back at 30 frames a second, the loop count is kept, transparent areas come back white, and a GIF with a single frame is refused: send a still as a photo. A GIF cannot take a lip-sync track.

One launch may name up to 200 files and carry up to 4 GB of them, a URL may be up to 2048 characters, and every video is billed for at least 10 seconds. These are what the app states and refuses on when the service's own catalogue cannot be read; the running deployment publishes its own figures and they are what the price is checked against at each request.

Generations and the uploads they were made from are deleted 30 days after the task reaches a terminal status, counted from the moment it finished rather than from the launch. Download what you want to keep. A task whose generations have gone keeps its row and its URLs, and purgedAt says when they went, which is the only thing that tells a URL that will still serve from one that will not.

