# API faceswap.ing

> Запускайте замену лиц на фото и видео из своего кода по API-ключу, затем опрашивайте задачу или принимайте подписанный вебхук и скачивайте результаты. Базовый URL: https://faceswap.ing/api/v1.

- Документ OpenAPI 3.1: https://faceswap.ing/api/v1/openapi.json
- Интерактивный справочник: https://faceswap.ing/ru/docs/api/endpoints
- Agent Skill для Claude, на английском: https://faceswap.ing/docs/api/skill.md. Как его установить: https://faceswap.ing/ru/docs/api/agents
- Эта документация на английском: https://faceswap.ing/docs/api.md
- Быстрый старт обновлён 2026-11-05, руководство обновлено 2026-11-10.

## Быстрый старт

От API-ключа до скачанных результатов, в том порядке, в каком это делает конвейер: проверить ключ, запустить, дождаться результата или принять вебхук, скачать. Все примеры написаны на обычном HTTP через fetch или requests, SDK ставить не нужно, а ключ читается из FACESWAP_API_KEY. Примеры опираются друг на друга: следующие используют API, headers и функции, объявленные в предыдущих.

### Получите ключ и проверьте его

Создайте ключ в настройках, в разделе «Разработчикам», и выберите команду, из кошелька которой он будет тратить swapz. Ключ видит лица, задачи и апскейлы этой команды и больше ничего. Полный ключ показывается один раз, поэтому скопируйте его сразу.

Храните ключ в переменной окружения или в хранилище секретов. Все примеры здесь читают FACESWAP_API_KEY и передают ключ в заголовке x-api-key. Не кладите его во фронтенд-код, там его прочитает любой.

Перед первым запуском владелец аккаунта один раз открывает приложение и подтверждает, что он совершеннолетний и что все, чьи лица он использует, на это согласились. До этого любой запуск получает отказ с declaration_required.

Этот запрос бесплатный. Ответ 200 со страницей лиц значит, что ключ работает. 401 значит, что ключа нет, в нём опечатка или он отозван.

**Проверить ключ, curl**

```bash
curl "https://faceswap.ing/api/v1/faces?pageSize=1" \
  -H "x-api-key: $FACESWAP_API_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());
```

**Проверить ключ, 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())
```

### Разместите файлы

Маршрута загрузки нет. Фото лица и все целевые файлы задаются публичными HTTPS URL, которые наш сервис скачивает во время замены, поэтому они должны открываться без ваших заголовков. Подписанный URL из S3, R2 или Google Cloud Storage тоже подойдёт, если он действует несколько часов.

Путь должен заканчиваться расширением файла: по нему API отличает фото от видео и считает цену. Для фото это .jpg, .jpeg, .png, .webp, .bmp, .heic, .heif, для видео .mp4, .mov, .avi, .mkv, .webm, .gif. Строка запроса после расширения, как у подписанных URL, не мешает.

Ограничения на размер, длину и кадр перечислены в руководстве, в разделе «Медиафайлы». Файл, который нарушает ограничение, отклоняется при запуске, ещё до удержания swapz.

### Запустите замену

Отправьте POST /faceswap/tasks с лицом и до 200 целевых файлов. Фото и видео можно смешивать в одном запуске. Запуск проверяет каждый файл, который хостите вы, до того, как что-либо оценить, поэтому дайте запросу тайм-аут от минуты.

Запуск, который прошёл, отвечает 200, и в data лежат созданные задачи. Сохраните каждый taskId: большой или смешанный запуск делится на несколько задач, которые выполняются параллельно. Если запуск получил отказ до того, как что-то создал, приходит статус ошибки и тело application/problem+json: ветвитесь по его code, как показано ниже, в разделе «Ошибки». Если отказ пришёл на полпути, ответ всё равно 200: в data лежат задачи, которые успели создаться, а в refusal причина отказа. Когда запуск прошёл целиком, refusal равен null.

swapz за весь запуск удерживаются из кошелька команды в момент запуска. Когда задача завершается, списывается стоимость готовых файлов, а за каждый файл с ошибкой swapz возвращаются.

В конвейере отправляйте каждый запуск с Idempotency-Key, чтобы повтор не запустил его дважды. Как это сделать, показано ниже, в разделе «Повторы и дубликаты».

**Запуск, 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" }]
  }'
```

**Запуск, 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);
```

**Запуск, 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"]]
```

**Сокращённый ответ**

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

### Дождитесь результата

Читайте GET /faceswap/tasks/{taskId} каждые 5 или 10 секунд, пока status не станет одним из cancelled, completed, failed, partially_completed. Чтение бесплатное.

Ключ может сделать 120 запросов на чтение за 60 секунд. Если задач в работе больше нескольких, читайте вместо этого GET /faceswap/tasks?status=queued,processing: один запрос возвращает до 100 задач. Или обойдитесь без опроса и примите вебхук.

**Дождаться завершения задачи, curl**

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

**Дождаться завершения задачи, 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);
  }
}
```

**Дождаться завершения задачи, 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)
```

### Или примите вебхук

Зарегистрируйте один HTTPS-эндпоинт на ключ в настройках, в разделе «Разработчикам», в меню ключа. При сохранении один раз показывается секрет подписи. Когда задача, запущенная этим ключом, завершается, мы отправляем на эндпоинт POST с событием, внутри которого лежит задача в той же форме, что возвращает GET /faceswap/tasks/{taskId}.

Доставки подписываются по стандарту Standard Webhooks (standardwebhooks.com), поэтому проверить их может любая его библиотека с вашим секретом в том виде, в каком он показан: например, standardwebhooks для JavaScript и Python или библиотеки Svix. Чтобы проверить вручную, как в примере ниже, сверяйте подпись по сырому телу до разбора JSON. webhook-signature содержит v1, запятую и HMAC-SHA256 в base64 от webhook-id, точки, webhook-timestamp, точки и тела. Ключом служат байты, которые кодирует ваш секрет после whsec_. Подписей в заголовке может быть несколько через пробел: доставку принимайте, если совпала хотя бы одна. Отклоняйте метку времени, которая расходится с вашими часами больше чем на 5 минут.

Ответьте 2xx в течение 10 секунд, а работу делайте потом, из своей очереди. Любой другой ответ приводит к повтору, всего 6 попыток. Доставка гарантируется как минимум один раз, поэтому сохраняйте webhook-id и пропускайте id, который уже видели.

После 10 неудач подряд эндпоинт отключается, пока его не включат в настройках. События за это время повторно не отправляются, поэтому конвейеру, который полагается на вебхуки, стоит раз в день читать список недавних задач.

**Проверить доставку, 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);
  });
}
```

**Проверить доставку, 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)
    )
```

**Принять событие, 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);
});
```

**Принять событие, 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
```

### Скачайте результаты

У каждого готового файла есть outputUrl, обычная HTTPS-ссылка, для которой ключ не нужен. У файла, который не готов, есть статус и errorCode с причиной, и он ничего не стоит.

Копируйте результаты в своё хранилище. Генерации и файлы, из которых они сделаны, удаляются через 30 дней после завершения задачи. Когда это произошло, показывает purgedAt у задачи.

**Скачать готовые файлы, curl**

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

**Скачать готовые файлы, 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()));
}
```

**Скачать готовые файлы, 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)
```

### Ошибки и что с ними делать

Любая ошибка приходит документом application/problem+json. Ветвитесь сначала по HTTP-статусу, затем по code. detail является фразой для лога, и её формулировка может измениться.

Все коды отказа в запуске и все errorCode, которые может получить файл с ошибкой, перечислены в руководстве.

| Ответ | Что случилось | Что делать |
| --- | --- | --- |
| 401 api_key_missing, api_key_invalid | Ключа нет, в нём опечатка, он отозван или истёк. | Исправьте ключ. Повтор не поможет. |
| 403 organization_suspended | Рабочее пространство ключа приостановлено. | Не отправляйте запросы, пока ограничение не снимут. |
| 404 not_found | Задача не принадлежит команде этого ключа. | Проверьте id и ключ. |
| 422 validation | Запрос не совпадает со схемой. property указывает на первое поле с ошибкой. | Исправьте запрос по справочнику. |
| 429 rate_limited | Слишком много запросов с этого ключа в текущем окне. | Подождите Retry-After секунд и отправьте тот же запрос. |
| 409 idempotency_key_in_progress | Первый запуск с этим Idempotency-Key ещё не ответил. | Подождите Retry-After секунд и отправьте тот же запрос с тем же ключом идемпотентности. |
| 422 idempotency_key_reused | Этот Idempotency-Key уже присылали с другим телом. Ничего не запущено. | Для другого запуска нужен новый ключ идемпотентности. |
| 503 moderation_unavailable, media_checking, service_busy | Запуск пока отклонён, ничего не удержано. | Подождите Retry-After секунд (30) и отправьте тот же запуск снова. |
| 402 insufficient_swapz | У команды не хватает swapz на запуск. Оба числа есть в shortfall. | Пополните кошелёк и отправьте запуск снова. |
| 403 declaration_required | Владелец аккаунта не сделал подтверждение. | Откройте приложение и подтвердите. |
| 400 moderation_refused, file_too_large, video_too_long и другие ограничения | Файл отклонён и будет отклонён снова. | Замените этот файл или уберите его. |
| 502 launch_failed | Сбой на нашей стороне. Ничего не удержано. | Отправьте запуск позже. |

### Лимиты запросов

Каждый ключ может сделать 120 запросов на чтение за 60 секунд и 30 запусков за 60 секунд, и считаются они отдельно. Окна фиксированы и выровнены по часам. Сверх лимита приходит 429 с заголовком Retry-After, а отклонённый запрос в счёт не идёт.

Один запуск несёт до 200 файлов, так что пакет из нескольких сотен файлов укладывается в несколько запусков. Отправляйте их один за другим и пережидайте 429, если он придёт. Два ключа никогда не делят счётчик.

### Повторы и дубликаты

Любой GET можно безопасно повторять, как и любой запрос, получивший 429: такой запрос отклоняется до того, как его прочитали.

Отправляйте каждый запуск с заголовком Idempotency-Key. В нём передаётся ключ идемпотентности: новый UUID на каждый запуск, который вы сохраняете и без изменений отправляете с каждым повтором этого запуска. Если ответ потерялся из-за тайм-аута или обрыва соединения, отправьте тот же запрос с тем же ключом. Если первый запрос создал задачи, повтор получит сохранённый ответ и заголовок Idempotent-Replayed: true, и ничего не запустится и не удержится дважды. Ключ идемпотентности хранится 24 часа и привязан к API-ключу, который его отправил.

Сохраняется только запуск, который создал задачи. Запуск, отклонённый до удержания, например с insufficient_swapz, moderation_unavailable, service_busy или launch_failed, освобождает ключ. Когда вы устраните причину, тот же запрос с тем же ключом выполнится. Запуск, отклонённый на полпути, с задачами в data, сохраняется как успешный.

Пока первый запрос с этим ключом идемпотентности ещё выполняется, повтор получает 409 с idempotency_key_in_progress и заголовком Retry-After. Подождите столько и отправьте снова. Ключ идемпотентности, отправленный повторно с другим телом, получает 422 с idempotency_key_reused и ничего не запускает, потому что другому запуску нужен новый ключ. Каждый повтор расходует лимит на запуски.

Если повтор получает 409 несколько минут подряд, первый запрос с этим ключом идемпотентности оборвался на полпути, и неизвестно, запустился ли он. Прежде чем запускать снова под новым ключом, прочитайте список последних задач. Запуск без заголовка не дедуплицируется никогда, и перед его повторной отправкой нужна та же проверка.

Вебхук может прийти дважды, а события разных задач могут прийти не по порядку. Отсеивайте дубликаты по webhook-id и считайте конечный статус задачи окончательным: он больше не меняется.

**Один запуск при любом числе повторов, 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" }]
  }'
```

**Один запуск при любом числе повторов, 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" }],
});
```

**Один запуск при любом числе повторов, 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"}],
})
```

### Встройте API в свой конвейер

Пакетная обработка. Сгруппируйте целевые файлы по лицу и отправляйте до 200 файлов за запуск, а затем ждите по id задач или по вебхукам. Пример ниже использует post, waitFor и wait_for из предыдущих шагов.

Обработчики очередей. Одно сообщение, один запуск: сохраните taskIds рядом с сообщением и отмечайте задание выполненным, когда придёт вебхук по каждой задаче. Ежедневный список последних задач подберёт то, что пропустил отключённый эндпоинт.

n8n, Make, Zapier и другие HTTP-шаги. Используйте шаг HTTP-запроса: метод POST, URL https://faceswap.ing/api/v1/faceswap/tasks, заголовок x-api-key с ключом из хранилища учётных данных инструмента и JSON-тело, как в запуске выше. Дальше либо крутите цикл из паузы и GET задачи, либо запускайте второй сценарий от триггера вебхука. Чтобы проверить там подпись, триггер должен отдавать сырое тело.

Типизированные клиенты. Документ OpenAPI по адресу https://faceswap.ing/api/v1/openapi.json описывает каждый маршрут и поле, поэтому генератор вроде openapi-typescript или OpenAPI Generator соберёт клиент для вашего языка.

**Пакет: одно лицо на много файлов, 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));
```

**Пакет: одно лицо на много файлов, 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]
```

**Типы для TypeScript из документа OpenAPI**

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

## Руководство по API

Те же замены и апскейлы, что запускает приложение, только из вашего кода. Ключ тратит swapz своей команды и видит её лица, генерации и апскейлы. Больше ему ничего не доступно.

### Обзор

Все маршруты находятся под https://faceswap.ing/api/v1. Запросы и успешные ответы передаются в JSON. Любая ошибка приходит документом application/problem+json, подробности в разделе «Ошибки». Ключ передаётся в заголовке x-api-key в каждом запросе. Другого способа аутентификации нет, и сессию открывать не нужно.

Ключи создаются в настройках, в разделе «Разработчикам». Личный ключ начинается с fsper_ и принадлежит тому, кто его создал. Ключ организации начинается с fsorg_, его создаёт владелец, и он принадлежит рабочему пространству, поэтому отозвать его может любой владелец. При создании ключ любого вида привязывается к одной команде: он тратит её swapz и видит её лица, генерации и апскейлы. Когда аккаунт, создавший ключ, удаляют, ключ перестаёт работать.

Полный ключ показывается один раз, при создании. Мы храним только хеш и первые символы, поэтому потерянный ключ не восстановить. Его заменяют новым.

Владелец аккаунта один раз подтверждает в приложении, что ему есть 18 лет и что все, чьи лица он загружает, на это согласились. Пока подтверждения нет, любой запуск получает отказ с declaration_required, каким бы ключом он ни был сделан. Достаточно открыть приложение и начать замену, после этого сразу работают все ключи аккаунта.

Время приходит в формате метки времени Postgres с часовым поясом, например 2026-09-08 09:43:07.375+00. Идентификаторы имеют формат uuid. Работа оплачивается в swapz, а сколько стоит вызов, сказано ниже, в разделе «Цены».

Все маршруты ниже описаны документом OpenAPI 3.1 по адресу https://faceswap.ing/api/v1/openapi.json. Он строится из моделей, по которым маршруты проверяют запросы, поэтому генератор клиента стоит направлять именно на него. Ключ для его чтения не нужен.

### Ошибки

Любая ошибка приходит документом application/problem+json (RFC 9457): { "type": "about:blank", "title": "Payment Required", "status": 402, "code": "insufficient_swapz", "detail": "..." }. В code лежит устойчивый код в snake_case, и ветвиться нужно именно по нему. В detail лежит наша английская фраза об этом случае, для человека или для лога: формулировка может измениться, у некоторых отказов её нет, и это никогда не сообщение конвейера обработки. title содержит стандартную фразу статуса, а status повторяет HTTP-статус. Если отказ касается чисел или файла, добавляется shortfall, processing или sourceUrl. Со временем могут появиться и новые поля, поэтому незнакомые просто пропускайте.

401 означает, что с этим ключом запрос выполнить нельзя: code равен api_key_missing, если заголовка x-api-key нет, и api_key_invalid, если ключ не проходит проверку. api_key_invalid получают и ключ, чей аккаунт удалён, и ключ без записанной команды, и отозванный ключ.

403 с code organization_suspended: рабочее пространство, которому принадлежит ключ, приостановлено, и ни один вызов не пройдёт, пока ограничение не снимут.

На id задачи или апскейла, которыми команда ключа не владеет, приходит 404 с code not_found. Чужую запись из другого рабочего пространства нельзя отличить от несуществующей.

429 с code rate_limited и заголовком Retry-After: ключ сделал слишком много запросов в текущем окне. Сколько это, сказано в разделе «Лимиты запросов».

422 с code validation означает, что запрос не совпадает с моделью маршрута: { "type": "validation", "code": "validation", "title": "Validation Error", "status": 422, "on": "body", "property": "/name" }. Поле "on" говорит, какая часть запроса не прошла проверку: body, query или params. Поле "property" содержит JSON pointer на первое поле, которое не совпало со схемой. Что именно не так, тело не объясняет и присланное не повторяет. Сверьте запрос с моделью маршрута в документе OpenAPI. Тело, которое вообще не является JSON, получает 400 с code parse.

Запуск, отклонённый до того, как он что-то создал, приходит со статусом самого отказа, что у замены, что у апскейла, и ничего не удерживается. 400 означает файл или очередь, которые запуск не может взять так, как их просят, и тот же запрос получит тот же отказ. 402 означает insufficient_swapz вместе с shortfall. 403 означает declaration_required или feature_not_allowed: снять такой отказ может только тот, у кого есть доступ к аккаунту. 502 означает launch_failed или unpriced_queue, то есть сбой на нашей стороне. 503 означает moderation_unavailable, media_checking или service_busy, которые проходят сами, и приходит с Retry-After: 30. Отправьте тот же запрос снова через столько секунд.

Запуск замены может получить отказ и на полпути, когда часть задач уже создана. Тогда приходит 200: в "data" лежат задачи, созданные до остановки, они выполняются и оплачиваются как обычно, а в "refusal" лежит документ ошибки с причиной, по которой остальное не запустилось. У запуска, который прошёл целиком, "refusal" равен null. Проверяйте refusal в каждом ответе 200 и не рассчитывайте, что запустилась вся очередь.

code равен declaration_required, если владелец аккаунта не сделал подтверждение, описанное выше. Запрос менять не нужно, достаточно один раз подтвердить.

code равен moderation_unavailable, если указанный файл не удалось проверить: сервис проверки не ответил вовремя или не смог прочитать URL. Ничего не оценено, не удержано и не запущено, а сам файл не получил вердикта. Такой отказ стоит повторить. Отправьте тот же запрос, когда пройдут секунды из Retry-After, и увеличивайте паузу, если отказ повторится.

code равен moderation_refused, если проверка посмотрела файл и отклонила его. Этот файл будет отклонён снова, повторять его не нужно.

code равен media_not_in_library, если указанный URL лежит на нашем файловом хосте и не принадлежит вашей команде: это не файл из библиотеки команды, не outputUrl готовой замены или апскейла команды и не образец, указанный как целевой файл. Образец означает одно из фото и видео, которые сайт предлагает любому аккаунту, чтобы попробовать замену; его можно указать только как целевой файл. Ничего не удержано, и тот же запрос получит тот же отказ. Если файл из библиотеки команды ещё проверяется, приходит media_checking: он проходит сам, отправьте тот же запрос, когда пройдут секунды из Retry-After. Если файл отложен без проверки, приходит media_held, а если его так и не проверили, media_unjudged: загрузите его в приложении заново.

shortfall имеет вид { "available": 12, "needed": 40 } и приходит вместе с detail, где названы оба числа. Очередь оценена, но у команды не хватает swapz. Купите swapz и отправьте тот же запрос снова.

code равен file_over_processing_cap, если одному файлу нужно больше времени обработки, чем разрешено на файл. Вместе с ним приходит processing, { "capMinutes": 90, "estimatedMinutes": 112 }, и ничего не удержано. Тот же запрос получит тот же отказ. Обрежьте файл, отправьте его с меньшим maxMegapixels или отключите какую-нибудь опцию.

code равен gif_unavailable, если целевой файл в формате GIF, а работающее развёртывание GIF не обрабатывает. gif_too_long означает GIF длиннее, чем разрешено GIF такого размера, а gif_too_large означает GIF со слишком большим кадром. Во всех трёх случаях ничего не удержано, и тот же запрос получит тот же отказ.

code равен video_unmeasured, если целевой файл является видео из библиотеки рабочего пространства без записанной длительности, и оценить его нельзя. durationSeconds в запросе её не заменяет. Загрузите файл заново или передайте trim. Ничего не удержано.

code равен insufficient_swapz, если у команды нет суммы, которую стоит очередь. feature_unavailable означает, что запрос просит то, чего нет в работающем развёртывании, feature_not_allowed означает опцию, которой этому рабочему пространству пользоваться нельзя, а face_unavailable означает сохранённое лицо из состава без пригодного фото. Опцию или числа называет detail.

code равен service_busy, если очередь заполнена. Ничего не удержано, и тот же запрос можно отправить снова, когда пройдут секунды из Retry-After. launch_failed означает сбой на нашей стороне. Ничего не удержано, сбой записан в лог.

Файл или очередь, которые нарушают ограничение, получают отказ с кодом этого ограничения, и ничего не удерживается: 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 или feature_disabled. quote_refused означает очередь, которая не может запуститься в таком виде по причине, для которой нет более точного кода. Код, которого нет в этом списке, считайте отказом, который повторится.

Задача, которая запустилась и потом завершилась ошибкой, отказом не считается. Это видно в самой задаче. У файла с ошибкой есть errorCode и error, и у задачи, которая завершилась ошибкой или была закрыта, тоже. errorCode является стабильным полем: 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 содержит одну постоянную английскую фразу на код, а у кода без своей фразы там "Something went wrong. The error code, if any, says what.". У файла или задачи без ошибки оба поля равны null. До 2026-10-06 в error попадало собственное сообщение конвейера обработки. Теперь это не так, поэтому не сравнивайте его текст.

Отказ в запуске апскейла устроен так же, через статус. Его собственные коды, а также случаи, когда sourceUrl называет фото, перечислены ниже, в разделе «Апскейл».

У запуска с заголовком Idempotency-Key есть два собственных отказа, и оба приходят до того, как что-либо запущено. 409 с code idempotency_key_in_progress и заголовком Retry-After означает, что первый запрос с этим значением заголовка ещё не ответил. Подождите и отправьте тот же запрос снова. 422 с code idempotency_key_reused означает, что это значение уже присылали с другим телом. Для другого запуска нужно новое значение. Значение заголовка, которое не укладывается в 255 печатаемых символов ASCII или пустое, считается обычной ошибкой валидации с "on": "headers".

**Отказ в запуске**

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

### Лимиты запросов

Лимиты считаются для каждого ключа, и чтение считается отдельно от запусков. Ключ может сделать 120 запросов на чтение за 60 секунд по маршрутам GET и 30 запусков за 60 секунд на POST /faceswap/tasks и POST /upscales вместе. Один запуск замены несёт до 200 файлов, а один апскейл до 50 фото, поэтому лимит на запуски ограничивает запросы, а не файлы.

Окна фиксированные и выровнены по часам: каждые 60 секунд счёт начинается заново. Запрос сверх лимита получает 429 с code rate_limited и заголовком Retry-After, в котором указано, сколько секунд осталось до конца окна. Подождите столько и отправьте тот же запрос снова. Отклонённый запрос ничего не стоит и в счёт не идёт.

Ключ, который исчерпал запуски, может читать, и наоборот. Два ключа никогда не делят счётчик, даже в одной команде. Запрос без ключа или с неверным ключом отклоняется раньше, чем что-либо посчитано.

Чтобы держаться далеко от лимита на чтение, используйте вебхук. Зарегистрируйте его на ключе, и результат задачи придёт к вам сам, когда она завершится, без опроса.

### Пагинация

Все списки постраничные, с курсором. Страница содержит data, pageSize и nextCursor, который на последней странице равен null.

Чтобы получить следующую страницу, передайте полученный курсор в параметре cursor. Номеров страниц и общего количества нет. Курсор указывает на последнюю строку, которую вы видели, поэтому строки, записанные во время обхода, не сдвигают следующую страницу.

Строки идут от новых к старым, по времени создания, а затем по id. Поэтому у двух строк, записанных в один момент, порядок всё равно определён.

### Лица

Лицо состоит из имени и до 5 фото одного человека и сохраняется в приложении. API лица читает, но не создаёт: лицо собирается из фото, которые уже лежат в библиотеке команды, а маршрута загрузки у ключей нет.

imageUrl содержит первое фото, оно основное. В imageUrls лежат все фото по порядку. Лицо, у которого удалили все фото, приходит с пустым списком и imageUrl, равным null. Для таких случаев и нужен фильтр with_image.

#### Список лиц

`GET /faces`

Сохранённые лица команды, от новых к старым. userId указывает, кто сохранил лицо. Владельцем считается команда, поэтому ключ видит все лица своей команды, а не только те, что сохранил его создатель.

Параметры запроса:

| Имя | Тип | Обязательный | Описание |
| --- | --- | --- | --- |
| `cursor` | string | Нет | Где закончилась предыдущая страница: значение nextCursor из её ответа. Для первой страницы не передавайте. Значение непрозрачное, и его формат мы можем поменять, поэтому храните его как есть и не разбирайте. |
| `pageSize` | integer | Нет | Сколько строк вернуть, от 1 до 100. По умолчанию 20. Если значение не число, используется значение по умолчанию, и запрос не падает. |
| `search` | string | Нет | Поиск по имени лица без учёта регистра, в любом месте имени. Пробелы по краям обрезаются, длина до 255 символов. |
| `status` | "with_image" | Нет | Единственный фильтр списка. Передайте with_image, чтобы получить только лица, у которых осталось хотя бы одно фото. Любое другое значение игнорируется, ошибки не будет. |

**Запрос, curl**

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

**Запрос, 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());
```

**Запрос, 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())
```

**Ответ**

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

### Замены

Один запуск может превратиться в несколько задач. Очередь делится по тому, что сервис принимает на одну задачу, то есть по дорожке для синхронизации губ и размеру кадра, а затем по бюджету обработки. Длинная очередь возвращается несколькими задачами, которые выполняются параллельно. Читайте весь массив data, а не только первый элемент.

targetFiles[].url указывает фото или видео, на которое ставится лицо. Это единственное обязательное поле файла. targetFiles[].facePosition выбирает, какое лицо в целевом файле заменить, считая с нуля слева направо. Без него заменяется первое лицо. targetFiles[].faceSelection принимает reference, one или many: reference заменяет лицо в позиции facePosition, one заменяет первое лицо слева, many заменяет все лица в файле. По умолчанию reference. targetFiles[].referenceFrameNumber указывает кадр, по которому определены эти позиции.

targetFiles[].swaps ставит в один целевой файл нескольких людей, каждого отдельным проходом, до 4 на файл. Каждый элемент принимает faceId из списка лиц, facePosition с лицом, которое он заменяет, и необязательный faceSelection. Два элемента не могут занять одну позицию. many работает, только если в составе один человек. Файл со swaps не должен указывать свой facePosition.

targetFiles[].trim принимает startSeconds и endSeconds и обрабатывает только этот отрезок. Сильнее всего на цену видео влияет именно он. Конец отрезка должен быть позже начала, и обрезать можно только видео. targetFiles[].lipSync принимает audioUrl и необязательный weight от 0 до 1 и двигает губы по этой дорожке. Синхронизация губ работает только на видео, а очередь с двумя дорожками становится двумя задачами.

targetFiles[].maxMegapixels обрабатывает файл с разрешением не выше заданного числа мегапикселей, один раз уменьшая его перед заменой. Благодаря этому загрузка в 4K оплачивается по тарифу 1080p. Предел 8.9 МП. targetFiles[].durationSeconds передаёт длительность видео, как вы её измерили, и используется, только если проба не смогла её определить. targetFiles[].thumbnailUrl задаёт кадр для превью файла и никогда не обрабатывается.

processors.ageModifier принимает ageShift, целое число от -100 до 100. У этой опции одной нет значения по умолчанию, потому что нулевой сдвиг возраст не меняет. processors.expressionRestorer принимает strength от 0 до 1 и переносит на новое лицо мимику из целевого файла. processors.backgroundRemover принимает fillColor из четырёх каналов от 0 до 255: красный, зелёный, синий и альфа. По умолчанию фон прозрачный, а прозрачность сохранится, только если целевой файл в PNG.

processors.frameEnhancer принимает scale, 2 или 4, и blend от 0 до 1. processors.frameColorizer принимает blend от 0 до 1. processors.faceEditor меняет само лицо по четырнадцати осям, каждая от -1 до 1, а 0 оставляет ось как есть: eyebrowDirection, eyeGazeHorizontal, eyeGazeVertical, eyeOpenRatio, headPitch, headRoll, headYaw, lipOpenRatio, mouthGrim, mouthPositionHorizontal, mouthPositionVertical, mouthPout, mouthPurse и mouthSmile. processors.watermark принимает boolean.

Все процессоры, кроме watermark, передаются объектами, и процессор включается самим присутствием. Пустой объект запускает его с настройками сервиса по умолчанию. Для простой замены не передавайте поле processors совсем.

Задача сначала стоит в статусе queued, затем переходит в processing, а затем в один из четырёх конечных статусов. completed означает, что готовы все файлы, partially_completed означает, что готова только часть, а ещё есть failed и cancelled. cancelling конечным не является: отмена принята, и обработчик доделывает текущий файл. Статусы меняются по таблице переходов, поэтому событие, пришедшее поздно или дважды, не откатит задачу назад, а конечный статус уже не меняется.

У каждого файла задачи свой статус: pending, processing, completed, failed, insufficient_coins для файла, за который никто не смог заплатить, и system_cancelled, system_maintenance или user_cancelled для трёх видов отмены. cost показывает, сколько swapz списано за эту генерацию, и заполняется, когда файл готов.

За файл с ошибкой swapz возвращаются. То, что удержано за задачу, один раз сверяется с фактической стоимостью файлов, и разница возвращается на постоянный баланс команды.

#### Запустить замену

`POST /faceswap/tasks`

Оценивает очередь, удерживает swapz и отправляет её в работу. В ответе приходят созданные задачи, у каждой уже есть её файлы. Запуск, отклонённый до создания задач, получает статус отказа и документ ошибки, а отклонённый на полпути получает 200, где отказ лежит рядом с созданными задачами. Подробности в разделе «Ошибки». Отправляйте Idempotency-Key с каждым запуском, чтобы повтор после потерянного ответа не запустил замену дважды.

Заголовки:

| Имя | Тип | Обязательный | Описание |
| --- | --- | --- | --- |
| `Idempotency-Key` | string | Нет | Ваше значение для этого запуска, например UUID. Оно без изменений отправляется с каждым повтором запуска. В течение 24 часов после первого запроса повтор с тем же значением и тем же телом от того же API-ключа получает ответ первого запуска с заголовком Idempotent-Replayed: true и ничего не запускает и не удерживает. Сохраняется только запуск, который создал задачи. Запуск, отклонённый до удержания, освобождает значение, и тот же запрос с ним выполнится, когда его отправят снова. От 1 до 255 печатаемых символов ASCII. |

Тело:

| Имя | Тип | Обязательный | Описание |
| --- | --- | --- | --- |
| `faceEnhancer` | boolean | Нет | Включает или выключает улучшение лица. Сервис включает его по умолчанию, поэтому для выключения передайте false. Если поле не передано, улучшение остаётся включённым. |
| `faceId` | string | Нет | Сохранённое лицо, из которого взяты фото. Оно записывается в задачу, чтобы генерацию можно было связать с библиотекой. Фото по нему не загружаются, для этого есть faceImageUrl. |
| `faceImageUrl` | string (url) | Да | Лицо, которое нужно поставить. Публичный HTTPS URL фото одного человека. Фото, которое хостите вы, загружается и проверяется при запуске замены; URL на нашем файловом хосте должен указывать на файл вашей команды. |
| `faceImageUrls` | string[] (url) | Нет | Дополнительные фото того же человека. Их эмбеддинги усредняются в одну внешность. До 4 помимо faceImageUrl, всего 5. Несколько ракурсов одного человека улучшают результат дешевле всего остального. |
| `metadata` | object | Нет | Ваш собственный набор ключей и значений. Он хранится в задаче и передаётся сервису обработки как есть. Мы его не читаем, и в ответе на чтение задачи его нет. |
| `preset` | "balanced" \| "best" \| "fast" | Нет | Профиль качества и цены: balanced, best или fast. По умолчанию balanced. best добавляет к базовой цене 60%, fast стоит как balanced. Полный прайс находится на https://faceswap.ing/ru/pricing. |
| `processors` | object | Нет | Опции, которые выполняются вместе с заменой, в виде объектов. Присутствие опции включает её, пустой объект включает её с настройками сервиса по умолчанию, и каждая оплачивается сверх базовой цены. Поля каждой опции описаны выше. |
| `targetFiles` | object[] | Да | Фото и видео, на которые ставится лицо, от 1 до 200. Каждому элементу нужен url, остальные поля файла описаны выше. |

**Запрос, 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"
      }
    ]
  }'
```

**Запрос, 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());
```

**Запрос, 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())
```

**Ответ**

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

#### Список замен

`GET /faceswap/tasks`

Замены команды, от новых к старым, каждая со своими файлами. Прогресс выполняющейся задачи обновляется перед чтением страницы, поэтому проценты приходят актуальными. Строка содержит все поля, которые возвращает запуск. Пример ниже сокращён до полей, которые читает клиент при опросе.

Параметры запроса:

| Имя | Тип | Обязательный | Описание |
| --- | --- | --- | --- |
| `cursor` | string | Нет | Где закончилась предыдущая страница: значение nextCursor из её ответа. Для первой страницы не передавайте. Значение непрозрачное, и его формат мы можем поменять, поэтому храните его как есть и не разбирайте. |
| `pageSize` | integer | Нет | Сколько строк вернуть, от 1 до 100. По умолчанию 10. Если значение не число, используется значение по умолчанию, и запрос не падает. |
| `status` | статус задачи, один или несколько | Нет | Оставляет только задачи в этих статусах. Повторите параметр или перечислите значения через запятую. Неизвестные значения отбрасываются, а если допустимых не осталось, возвращается всё. |

**Запрос, curl**

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

**Запрос, 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());
```

**Запрос, 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())
```

**Ответ**

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

#### Прочитать одну замену

`GET /faceswap/tasks/:taskId`

Одна задача и её файлы, для опроса. Маршрут читает сохранённую строку и ничего не вызывает по пути, поэтому спрашивать его часто дёшево. Он возвращает одну задачу, а не страницу. На задачу, которой команда ключа не владеет, приходит 404. Пример ниже сокращён так же, как пример списка.

Параметры пути:

| Имя | Тип | Обязательный | Описание |
| --- | --- | --- | --- |
| `taskId` | string (uuid) | Да | id задачи, которой владеет команда ключа, из ответа на запуск или из списка. |

**Запрос, curl**

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

**Запрос, 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());
```

**Запрос, 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())
```

**Ответ**

```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
    }
  ]
}
```

### Апскейл

Апскейл увеличивает фото до 2K, 4K или 8K и восстанавливает детали, которых не хватает маленькому или мягкому снимку. Пропорции каждого фото сохраняются. Один запуск принимает от 1 до 50 фото, у каждого свой размер, и всегда даёт один апскейл: в отличие от замены, он не делится. У апскейла свои маршруты и своё событие вебхука, поэтому для клиента, который им не пользуется, в заменах ничего не меняется.

files[].sourceUrl указывает фото, и это может быть одно из трёх. Публичный HTTPS URL фото, которое хостите вы, проверяется при запуске, как файлы замены. outputUrl готовой замены, сделанной командой этого ключа, берётся как есть, без новой проверки: он сделан из файлов, которые проверку уже прошли. Фото из библиотеки команды, загруженное через приложение, берётся после того, как прошло собственную проверку, а до этого получает отказ media_checking. Любой другой URL нашего хранилища отклоняется с media_not_in_library, чей бы он ни был.

files[].tier принимает 2k, 4k или 8k. files[].outputFormat принимает jpg, png или webp, а без него результат пишется в jpg. Одно фото можно запросить в одном запуске в двух размерах, но не дважды в одном. Фото может быть в формате JPG, PNG, WEBP, BMP, HEIC или HEIF, весить до 50 МБ и иметь до 50 МП. Видео, GIF и замена, сделанная из них, отклоняются с unsupported_media, а фото больше 50 МП отклоняется с image_too_large.

Размер задаёт итог, а не множитель. Результат получается самым большим, какой помещается в границы размера из таблицы ниже, и его длинная сторона не бывает больше 8192 пикселей. Если размер не удлиняет каждую сторону хотя бы в 1,25 раза, запуск отклоняется с tier_not_larger ещё до удержания: фото, которое уже почти 4K, апскейлом до 4K не продаётся. Если размер фото нам неизвестен, сервис измеряет его при запуске и отклоняет с теми же кодами, а удержанное возвращается.

Каждое фото стоит цену своего размера из таблицы ниже, или меньше, если у команды свой тариф: он применяется к каждому фото и округляется вверх до целого swapz. Итог удерживается при запуске апскейла. Когда он завершается, готовые фото сохраняют свою цену, а остальное возвращается, так что фото с ошибкой ничего не стоит.

Апскейл проходит те же статусы, что задача замены: queued, processing, затем completed, partially_completed, failed или cancelled. Каждое фото проходит те же статусы, что файл замены. У готового фото результат лежит в outputUrl, а размер в outputWidth и outputHeight. outputUrl равен null, пока результат не скопирован в наше хранилище.

У фото с ошибкой есть errorCode и error, как у файла замены с ошибкой, с кодами из раздела «Ошибки» и одним своим: tier_not_larger, если фото оказалось слишком большим, чтобы выбранный размер его увеличил.

Результаты удаляются через 30 дней после завершения апскейла, как и у замены, а deletedAt показывает, когда это случилось. Это верно и для результата, который потом стал входом для чего-то ещё. Фото, из которых сделан апскейл, вместе с ним не удаляются.

Отказ в запуске апскейла приходит со статусом самого отказа, никогда не с 200, и ничего не удерживает. Тело является документом ошибки, как описано в разделе «Ошибки». Ветвитесь по code. В detail лежит наша английская фраза, и формулировка может измениться.

sourceUrl называет фото всякий раз, когда мы отказываем ему до отправки апскейла: tier_not_larger, image_too_large и unsupported_media, если нам известны размер или тип, moderation_refused, moderation_unavailable, media_checking, а также media_not_in_library или другой код библиотеки. Если несколько фото получили отказ по одной причине, назван первый. Фото, размера которого у нас нет, измеряет сервис апскейла при создании задачи, и если он отказывает там, с tier_not_larger или image_too_large, отказ получает весь запуск без sourceUrl: сервис не сообщает, какое это фото. insufficient_swapz приходит с shortfall, а 502 и service_busy фото не называют.

400 означает фото, которое апскейл не может взять так, как его просят: tier_not_larger, image_too_large, unsupported_media, moderation_refused, media_not_in_library, другой код библиотеки или другое ограничение сервиса. Тот же запрос получит тот же отказ. 402 означает insufficient_swapz вместе с shortfall. 403 означает declaration_required или feature_not_allowed. 502 означает сбой на нашей стороне: launch_failed или unpriced_queue, если фото не удалось оценить.

503 проходит сам: moderation_unavailable, если ваше фото сейчас не удалось проверить, media_checking, если фото из библиотеки ещё проверяется, и service_busy, если очередь заполнена. 503 приходит с Retry-After: 30. Отправьте тот же запрос снова через столько секунд. Idempotency-Key работает так же, как при запуске замены, с теми же 409 и 422.

Когда апскейл завершается, вебхук ключа получает upscale.completed, подробности в разделе «Вебхуки».

| Размер | МП, не больше | Короткая сторона, не больше | Swapz за фото | 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 |

#### Запустить апскейл

`POST /upscales`

Находит и проверяет каждое фото, оценивает каждое по тарифу команды, удерживает итог и отправляет в работу. В ответе приходит созданный апскейл в той же форме, что у GET /upscales/:upscaleId, все фото в статусе pending, а cost равен цене фото. Отказ приходит HTTP-ошибкой, как описано выше.

Заголовки:

| Имя | Тип | Обязательный | Описание |
| --- | --- | --- | --- |
| `Idempotency-Key` | string | Нет | Ваше значение для этого запуска, например UUID. Оно без изменений отправляется с каждым повтором запуска. В течение 24 часов после первого запроса повтор с тем же значением и тем же телом от того же API-ключа получает ответ первого запуска с заголовком Idempotent-Replayed: true и ничего не запускает и не удерживает. Сохраняется только запуск, который создал апскейл, а отклонённый освобождает значение. От 1 до 255 печатаемых символов ASCII. |

Тело:

| Имя | Тип | Обязательный | Описание |
| --- | --- | --- | --- |
| `files` | object[] | Да | Фото, от 1 до 50. Каждое принимает sourceUrl и tier и необязательный outputFormat, как описано выше. |
| `metadata` | object | Нет | Ваши собственные ключи и значения. Хранятся в апскейле как есть и возвращаются с ним при каждом чтении и в его событии вебхука. Мы их не читаем. |

**Запрос, 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"
    }
  }'
```

**Запрос, 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());
```

**Запрос, 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())
```

**Ответ**

```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"
}
```

#### Список апскейлов

`GET /upscales`

Апскейлы команды, от новых к старым, каждый со своими фото. Апскейл, результаты которого удалены, всё равно есть в списке, с заполненным deletedAt. Пример ниже сокращён до полей, которые читает клиент при опросе.

Параметры запроса:

| Имя | Тип | Обязательный | Описание |
| --- | --- | --- | --- |
| `cursor` | string | Нет | Где закончилась предыдущая страница: значение nextCursor из её ответа. Для первой страницы не передавайте. Значение непрозрачное, и его формат мы можем поменять, поэтому храните его как есть и не разбирайте. |
| `pageSize` | integer | Нет | Сколько строк вернуть, от 1 до 50. По умолчанию 10. Если значение не число, используется значение по умолчанию, и запрос не падает. |

**Запрос, curl**

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

**Запрос, 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());
```

**Запрос, 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())
```

**Ответ**

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

#### Прочитать один апскейл

`GET /upscales/:upscaleId`

Один апскейл и его фото, для опроса, пока статус не станет конечным. На апскейл, которым команда ключа не владеет, приходит 404. Пример ниже сокращён так же, как пример списка.

Параметры пути:

| Имя | Тип | Обязательный | Описание |
| --- | --- | --- | --- |
| `upscaleId` | string (uuid) | Да | id апскейла, которым владеет команда ключа, из ответа на запуск или из списка. |

**Запрос, curl**

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

**Запрос, 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());
```

**Запрос, 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())
```

**Ответ**

```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
    }
  ]
}
```

### Вебхуки

У ключа может быть один вебхук: HTTPS URL, на который мы отправляем POST, когда завершается задача или апскейл, запущенные этим ключом. Зарегистрируйте его в настройках, в разделе «Разработчикам», в меню ключа. URL принадлежит ключу, и задаёт его человек, вошедший в аккаунт. Запуск указать его не может, и поля для обратного вызова у задачи нет.

URL должен быть HTTPS на стандартном порту, без имени пользователя и пароля, и вести на адрес, доступный из интернета. Это проверяется при сохранении и ещё раз перед каждой доставкой. Редиректы мы не выполняем, поэтому отвечайте на том URL, который зарегистрировали.

При сохранении URL один раз показывается секрет подписи, который начинается с whsec_. Он хранится в зашифрованном виде и больше не показывается. Если сохранить URL ещё раз, появится новый секрет, а старый сразу перестанет подписывать. Так секрет и меняют.

Событие отправляется, когда задача достигла конечного статуса и её списание окончательно. type принимает одно из значений task.completed, task.partially_completed, task.failed или task.cancelled. Расчёт по задаче проходит один раз, поэтому событие тоже одно. Апскейл по завершении отправляет upscale.completed, чем бы он ни закончился: как именно, показывает data.upscale.status. Кнопка «Отправить тестовое событие» в настройках отправляет webhook.test, подписанное так же.

Тело имеет вид { id, type, timestamp, data }. timestamp показывает, когда событие поставлено в очередь, в формате ISO 8601, например 2026-09-08T09:45:11.120Z, а не в формате меток Postgres, которыми отвечают маршруты.

data.task в точности совпадает с ответом GET /faceswap/tasks/:taskId для этой задачи, так что форма ответа одна. Это касается и ошибок: errorCode у задачи и у каждого файла с ошибкой остаётся кодом для ветвления, а error содержит нашу постоянную английскую фразу для него, а не сообщение конвейера обработки. data.chargedSwapz показывает итоговую стоимость задачи. Готовые файлы сохраняют свою цену, за остальные swapz возвращаются, и их cost равен 0. Для апскейла data.upscale совпадает с ответом GET /upscales/:upscaleId, а data.chargedSwapz читается так же.

Событие ждёт, пока результат каждого готового файла скопируется в наше хранилище, поэтому outputUrl к моменту доставки уже заполнен. Ждём мы не дольше 30 минут. После этого событие уходит всё равно, недостающий outputUrl равен null, и его нужно прочитать из задачи позже.

Доставки устроены по стандарту Standard Webhooks (standardwebhooks.com) и несут три его заголовка. webhook-id содержит id события. Он совпадает с id в теле и не меняется при повторах, поэтому дубликаты отсеивайте по нему. webhook-timestamp содержит момент подписи попытки в секундах от начала эпохи Unix. webhook-signature содержит v1, запятую и HMAC-SHA256 в base64 от id, точки, метки времени, точки и сырого тела запроса. Ключом HMAC служат байты, которые кодирует ваш секрет: base64 после whsec_, раскодированный, а не сам текст секрета. В заголовке может быть несколько подписей через пробел, и доставка подлинная, если совпала хотя бы одна.

Доставку проверит любая библиотека Standard Webhooks с секретом в том виде, в каком он показан: например, standardwebhooks для JavaScript и Python или библиотеки Svix, которые следуют той же схеме. Чтобы проверить вручную, как в примерах ниже, вычислите этот HMAC по полученным байтам до разбора JSON и сравните его за постоянное время. Отклоняйте доставку, если её метка времени расходится с вашими часами больше чем на 5 минут. Метка входит в подписанные данные, поэтому повторно отправленный запрос не может нести свежую.

Ответьте любым статусом 2xx в течение 10 секунд. Всё остальное считается неудачей, включая 3xx и отсутствие ответа вовремя. Тело ответа не читается. Неудачная доставка повторяется через 1 мин, 5 мин, 30 мин, 2 ч, 8 ч, всего 6 попыток. Очередь разбирается каждые тридцать минут, поэтому паузы указаны как минимальные. Доставка гарантируется как минимум один раз: если ответ пришёл сразу после тайм-аута, за ним последует повтор с тем же id.

После 10 неудачных попыток подряд эндпоинт отключается. Настройки показывают это на ключе и предлагают включить его снова. Пока он выключен, в очередь ничего не ставится, и события за это время потом не отправляются. Такие задачи прочитайте через API. Одна успешная доставка обнуляет счётчик.

Ключу, который сам не смог бы сделать запрос, ничего не отправляется. Это ключ отключённый, истёкший или отозванный, а также ключ приостановленного рабочего пространства. При отзыве ключа удаляется и его вебхук.

**Событие**

```
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
        }
      ]
    }
  }
}
```

**Файл с ошибкой в событии**

```
{
  "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."
        }
      ]
    }
  }
}
```

**Событие апскейла**

```
{
  "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
        }
      ]
    }
  }
}
```

**Проверка в 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);
  });
}
```

**Проверка в 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)
    )
```

### Цены

Вызов оплачивается по тому, что он обрабатывает, в swapz. Один swapz стоит $0.01, так что каждая цена ниже легко переводится в доллары. Маршруты GET бесплатны. Запуск стоит столько, сколько стоят его файлы, без надбавок, платы за запрос нет.

Фото стоит 5 swapz ($0.05), или 3 swapz ($0.03) за штуку, если в задаче от 20 фото. Видео оплачивается за каждую секунду после обрезки, по размеру кадра, и минимум за 10 секунд, поэтому самый короткий ролик стоит 30 swapz ($0.30).

Это базовые цены для пресетов balanced и fast. Пресет best добавляет 60%, а каждый процессор добавляет свою строку. Полный прайс со всеми опциями находится на https://faceswap.ing/ru/pricing, и приложение списывает по нему же.

Стоимость очереди удерживается с команды ключа при запуске задачи. Когда задача завершается, готовые файлы сохраняют свою цену, а остальное возвращается, так что файл с ошибкой ничего не стоит. cost у каждого файла и chargedSwapz в вебхуке показывают, что списано.

Поиск лиц на фото, который делает выбор лица в приложении, стоит там 1 swapz ($0.01). В API маршрута для него нет, поэтому ключ за него не платит.

Апскейл стоит 8 swapz ($0.08) за фото в 2K, 15 swapz ($0.15) за фото в 4K и 30 swapz ($0.30) за фото в 8K, какого бы размера ни было фото, без пресетов и опций. Удержание и возврат работают так же, по каждому фото.

| Что обрабатывается | Swapz | USD |
| --- | --- | --- |
| Одно фото | 5 | $0.05 |
| Одно фото в задаче от 20 фото | 3 | $0.03 |
| Видео 480p, до 0.6 МП на кадр, за секунду | 3 | $0.03 |
| Видео 720p, больше 0.6 и до 1 МП на кадр, за секунду | 4 | $0.04 |
| Видео 1080p, больше 1 и до 2.3 МП на кадр, за секунду | 6 | $0.06 |
| Видео 1440p, больше 2.3 и до 4 МП на кадр, за секунду | 9 | $0.09 |
| Видео 4K, больше 4 и до 8.9 МП на кадр, за секунду | 15 | $0.15 |
| Апскейл до 2K, за фото | 8 | $0.08 |
| Апскейл до 4K, за фото | 15 | $0.15 |
| Апскейл до 8K, за фото | 30 | $0.30 |

### Медиафайлы

Маршрута загрузки в API нет. Каждый файл, который вы указываете, задаётся публичным HTTPS URL, и сервис скачивает его сам. Поэтому файл должен открываться без ваших заголовков всё время, пока идёт замена.

Каждое изображение и видео, которое хостите вы, проверяется при запуске замены, до оценки и отправки в работу: целевые файлы, фото лица и превью. Если проверка отклоняет файл, отклоняется весь запуск с code moderation_refused. Если файл проверить не удалось, весь запуск тоже отклоняется, с code moderation_unavailable. Непроверенные файлы мы не обрабатываем, поэтому сбой проверки задерживает запуск, а не пропускает его. Такой отказ ничего не удерживает, и его можно безопасно повторить. Дорожку для синхронизации губ проверка оценить не может, это аудио, поэтому она скачивается как есть.

URL на нашем файловом хосте никогда не допускается через проверку. Он берётся как есть, если принадлежит вашей команде: файл из библиотеки команды, outputUrl готовой замены или апскейла команды, превью такой замены в роли превью или, в роли целевого файла, образец (одно из фото и видео, которые сайт предлагает, чтобы попробовать замену). Любой другой наш URL, в том числе файл или результат другой команды, отклоняет весь запуск с code media_not_in_library до оценки и удержания. Дорожка для синхронизации губ и превью на нашем хосте подчиняются тому же правилу.

Фото может весить до 50 МБ и иметь до 50 МП. Видео может весить до 1 ГБ, иметь до 8.9 МП в кадре и до 60 кадров в секунду. Если включён frameEnhancer, увеличенный результат может быть не больше 8192 пикселей по длинной стороне.

Допустимая длина видео зависит от кадра и считается после обрезки: 300 с до 1440p, 270 с до 4K. Эти значения верны при частоте до 30 кадров в секунду, на пресете balanced, с одной парой и без процессоров. За ними стоит ограничение на время обработки. Файл, обработка которого по оценке займёт больше 90 минут, отклоняется при запуске с code file_over_processing_cap ещё до удержания. Поэтому более высокая частота кадров, пресет best, дополнительные пары и процессоры сокращают допустимую длину.

Целевой файл, URL которого заканчивается на .gif, обрабатывается покадрово и возвращается в виде GIF. Он оплачивается посекундно, как видео, по тарифу своего кадра и с тем же минимумом в 10 секунд. GIF может весить до 100 МБ, длиться до 30 секунд и иметь кадр до 1 МП, а все его кадры вместе не могут превышать 150 МП. GIF 480x270 длится все 30 с при 30 кадрах в секунду, а 1280x720 только 5,4 с. Более быстрый GIF возвращается с частотой 30 кадров в секунду, число повторов сохраняется, прозрачные области становятся белыми, а GIF из одного кадра отклоняется: отправьте такой файл как фото. К GIF нельзя добавить дорожку для синхронизации губ.

Один запуск может указать до 200 файлов общим объёмом до 4 ГБ, длина URL не больше 2048 символов, а каждое видео оплачивается минимум за 10 секунд. Эти значения приложение показывает и применяет, когда не может прочитать каталог самого сервиса. Работающее развёртывание публикует свои значения, и при каждом запросе цена проверяется по ним.

Генерации и загрузки, из которых они сделаны, удаляются через 30 дней после того, как задача достигла конечного статуса. Срок считается от завершения задачи, а не от запуска. Скачайте то, что хотите сохранить. Задача, генерации которой удалены, сохраняет свою строку и URL, а purgedAt показывает, когда их удалили. Только по нему можно отличить URL, который ещё отдаёт файл, от того, который уже нет.

