Developer

API reference

Last updated

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

Overview

Every route lives under https://faceswap.ing/api/v1. Requests and successful responses are JSON; a refusal that never reached a handler is plain text, which the next section spells out. Send the key in an x-api-key header on every request: there is no other way to authenticate and no session to establish.

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

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

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

Times come back as Postgres timestamps with a zone, such as 2026-09-08 09:43:07.375+00. Ids are uuids. Work is paid for in swapz, and what each kind of file costs is set out at https://faceswap.ing/pricing.

Errors

401 with a plain-text body: the x-api-key header was missing (Missing API key) or the key does not verify (Invalid key). A key whose account was deleted, whose team was never written, or that has been revoked reads the same way.

403 with a plain-text body, This organization is suspended. The workspace the key belongs to is suspended, and no call it can make will succeed until that is lifted.

A 404 answers a task id the key's team does not own, as the body { "errorCode": "not_found" }. A task from another workspace is not distinguishable from one that never existed.

422 is a validation failure, as JSON: { "type": "validation", "on": "body", "property": "faceImageUrl", "message": "Invalid input: expected string, received undefined", "found": {}, "errors": [...] }. "on" says which part of the request failed, body, query or params, and "errors" carries one entry per field with its path.

A launch that is refused answers 200, not an error status: the request was understood and the queue was priced, and the answer is that it will not run. The body is { "data": [], "error": true, "errorMessage": "..." }, with an "errorCode" and a "shortfall" where there is one to give.

errorCode is declaration_required when the account holder has not made the declaration described above. It is the one code a launch names, and it is the one refusal a caller can clear without changing the request.

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

Every other refusal carries only errorMessage: a file moderation refused, an option this workspace may not use, a single file estimated past the task cap, or a queue whose balance no longer covered it by the time the hold was taken. The message says which.

A launch that partly succeeded is still error: true, and "data" holds the tasks that were created before it stopped. Read the array rather than assuming it is empty.

Pagination

Both lists are cursor paged. A page carries data, pageSize, and nextCursor, which is null on the last page.

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

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

Faces

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

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

List faces

GET /faces

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

Query

NameTypeRequiredDescription
cursorstringNoWhere the previous page ended, copied from its nextCursor. Omit it for the first page. The value is opaque and its format is ours to change, so store it rather than parsing it.
pageSizeintegerNoHow many rows to return, from 1 to 100. Defaults to 20. A value that is not a number falls back to the default rather than failing the request.
searchstringNoMatch on the face's name, case-insensitively, anywhere in it. Trimmed, and at most 255 characters.
status"with_image"NoThe only filter the list takes. Send with_image to return only faces that still have at least one photo. Anything else is ignored rather than refused.

Request

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

Response

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

Swaps

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

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

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

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

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

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

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

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

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

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

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

Launch a swap

POST /faceswap/tasks

Prices the queue, holds the swapz, and dispatches. The response carries the tasks it created, each already holding its files. A refusal answers 200 with error: true, described under Errors.

Body

NameTypeRequiredDescription
faceEnhancerbooleanNoTurn the face enhancer on or off. It is on by default at the service, so send false to turn it off; omitting the field leaves it on.
faceIdstringNoThe saved face these photos came from, recorded on the task so a generation can be traced back to the library. It does not fetch the photos; faceImageUrl does.
faceImageUrlstring (url)YesThe face to put on. A public HTTPS URL to a photo of one person, fetched and screened as the swap is launched.
faceImageUrlsstring[] (url)NoMore photos of the same person, whose embeddings are averaged into one identity. Up to 4 beside faceImageUrl, 5 in all. More angles of one person is the cheapest quality there is.
metadataobjectNoYour own bag of keys and values. It is stored on the task and passed through to the inference service as it arrived; nothing here reads it, and it is not part of what a task read gives back.
preset"balanced" | "best" | "fast"NoQuality and cost profile: balanced, best or fast. Defaults to balanced. best costs half as much again; fast is priced as balanced. The full rate card is at https://faceswap.ing/pricing.
processorsobjectNoThe options to run beside the swap, as objects. Present means on, an empty object means on with the service's defaults, and each is priced on top of the base. The fields of each are described above.
targetFilesobject[]YesThe photos and videos to swap onto, from 1 to 200 of them. Each entry needs a url and takes the per-file fields described above.

Request

curl -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" }
    ]
  }'

Response

{
  "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
        }
      ]
    }
  ],
  "error": false,
  "errorMessage": null
}

List swaps

GET /faceswap/tasks

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

Query

NameTypeRequiredDescription
cursorstringNoWhere the previous page ended, copied from its nextCursor. Omit it for the first page. The value is opaque and its format is ours to change, so store it rather than parsing it.
pageSizeintegerNoHow many rows to return, from 1 to 100. Defaults to 10. A value that is not a number falls back to the default rather than failing the request.
statustask status, one or manyNoKeep only tasks in these statuses. Repeat the key or separate the values with commas. Unknown values are dropped, and a filter left with nothing valid returns everything.

Request

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

Response

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

Read one swap

GET /faceswap/tasks/:taskId

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

Path

NameTypeRequiredDescription
taskIdstring (uuid)YesThe id of a task the key's team owns, as returned by a launch or by the list.

Request

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

Response

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

Media

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

Each URL is screened as the swap is launched, before anything is priced or dispatched: the target files, the face photos, the lip-sync track and the thumbnail. A file the screening refuses refuses the whole launch, with That file was refused by moderation. If the screening service cannot be reached the file is allowed through, because an outage of the check must not become an outage of the tool.

A photo may be up to 50 MB and 50 megapixels. A video may be up to 1 GB, 5 minutes measured after any trim, 8.9 megapixels a frame and 60 frames a second. Output is never wider than 8192 pixels.

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

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