Quickstart

Last updated

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 "https://faceswap.ing/api/v1/faces?pageSize=1" \
  -H "x-api-key: $FACESWAP_API_KEY"
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());
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 -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" }]
  }'
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);
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 "https://faceswap.ing/api/v1/faceswap/tasks/01a08066-5d70-7000-a3db-ff3ef6b651ca" \
  -H "x-api-key: $FACESWAP_API_KEY"
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);
  }
}
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

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);
  });
}
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

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);
});
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

# outputUrl needs no key.
curl -L -o result.jpg "OUTPUT_URL_FROM_THE_TASK"
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()));
}
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.

AnswerWhat happenedWhat to do
401 api_key_missing, api_key_invalidThe key is missing, mistyped, revoked or expired.Fix the key. Retrying will not help.
403 organization_suspendedThe workspace the key belongs to is suspended.Stop calling until it is lifted.
404 not_foundThe task is not in this key's team.Check the id and the key.
422 validationThe request does not match the schema. property points at the first field that failed.Fix the request against the reference.
429 rate_limitedToo many requests from this key in the current window.Wait Retry-After seconds and send the same request.
409 idempotency_key_in_progressThe 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_reusedThis 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_busyA launch refused before anything was held, for now.Wait Retry-After seconds (30) and send the same launch again.
402 insufficient_swapzThe team does not hold what the launch costs. shortfall has both numbers.Top up the wallet, then send it again.
403 declaration_requiredThe 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 limitsA file was refused, and will be refused again.Change or drop that file.
502 launch_failedA 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

# 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" }]
  }'
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" }],
});
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

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