all dispatches
Sep 17, 202614 min read

Face Swap API for Consumer Apps: Integration to Consent

What a face swap API needs in a consumer app: integration, output quality, moderation and consent.

Face Swap API for Consumer Apps: Integration to Consent

AI Face Swap V1 takes two image URLs: source_image, the image being edited, and face_image, the identity to transfer. It does not require a text prompt.

Shipping that inside a consumer app is the larger job.

Users start with files on phones and laptops, not public URLs. An upload may violate your content rules. An acceptable image does not prove that the uploader has permission to use the person's likeness. Generation is asynchronous. The files still need a lifecycle after the result comes back.

The production path looks more like this:

Select images
      ↓
Confirm application-level consent
      ↓
Upload images
      ↓
Moderate inputs
      ↓
Allow / reject / review
      ↓
Run face swap
      ↓
Poll prediction
      ↓
Optional output moderation
      ↓
Return result
      ↓
Clean up uploaded media

This guide builds that path around each::labs AI Face Swap V1. It covers the current still-image model and the API surface we can verify today.

Two faces, one geometry. The grid decides the result.
Two faces, one geometry. The grid decides the result.

What the face swap request actually needs

The current model slug is aifaceswap-face-swap.

Its request is equally compact:

{
  "model": "aifaceswap-face-swap",
  "input": {
    "source_image": "https://example.com/source.jpg",
    "face_image": "https://example.com/face.jpg"
  }
}

source_image is the image being edited. face_image contains the identity you want to transfer.

Field Purpose
source_image The image being edited
face_image The face/identity image being transferred
Text prompt Not required
Quality parameter None verified in the current request schema

There is no prompt to tune. The current callable example also exposes no blend strength, restoration amount, seed, quality tier, or resolution field.

Some descriptive copy around the model goes further and mentions video, multiple faces, resolution settings, and quality tiers. The current two-field request does not establish those capabilities, and the family page describes this version as a still-image model. This guide sticks to the narrower contract.

Why the request does not include version

You will still see model-card examples containing:

"version": "0.0.1"

The current machine-readable each::api contract marks version as deprecated and ignored. model and input are the required fields, so the examples here leave it out.

Some human-readable documentation still describes a version as required. The documentation has not fully converged yet; the code in this guide follows the current API contract.

Authentication is less ambiguous:

Authorization: Bearer YOUR_API_KEY

Keep that key on the server. A browser or mobile client should not ship a reusable API credential.

Upload local photos before calling the model

Most API examples begin with URLs. Consumer products usually begin with two files selected from a browser picker or phone library.

each::api exposes a presigned-upload endpoint for that boundary:

POST /v1/upload/presign
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json

For an image:

{
  "content_type": "image/jpeg",
  "file_type": "image"
}

content_type is required. The response can include an upload id, presigned_url, public_url, expiration time, and required_headers.

The flow is straightforward:

  1. request a presigned upload;
  2. PUT the local file bytes to presigned_url;
  3. send the same Content-Type used to request the presign, plus every returned required_headers entry;
  4. keep the upload id for cleanup;
  5. pass public_url into moderation and generation.

A reusable helper looks like this:

import mimetypes
from pathlib import Path

import requests


API_BASE = "https://api.eachlabs.ai/v1"


def api_headers(api_key: str) -> dict[str, str]:
    return {"Authorization": f"Bearer {api_key}"}


def upload_image(api_key: str, file_path: str) -> dict[str, str]:
    path = Path(file_path)
    content_type, _ = mimetypes.guess_type(path.name)

    if not content_type or not content_type.startswith("image/"):
        raise ValueError(f"Unsupported image type: {path}")

    response = requests.post(
        f"{API_BASE}/upload/presign",
        headers={
            **api_headers(api_key),
            "Content-Type": "application/json",
        },
        json={
            "content_type": content_type,
            "file_type": "image",
        },
        timeout=30,
    )
    response.raise_for_status()
    upload = response.json()

    with path.open("rb") as file:
        put_response = requests.put(
            upload["presigned_url"],
            data=file,
            headers={
                "Content-Type": content_type,
                **(upload.get("required_headers") or {}),
            },
            timeout=120,
        )
        put_response.raise_for_status()

    return {
        "id": upload["id"],
        "public_url": upload["public_url"],
    }

The upload API also accepts optional expires_in_seconds for storage lifetime. The current contract allows values from 60 seconds through 365 days and defaults to 180 days when the field is omitted. Treat that as storage configuration, not as a promise that your application should make without its own retention policy.

Quality is an input problem wearing the model's coat.
Quality is an input problem wearing the model's coat.

Face swap quality is mostly an input problem

When a swap looks bad, the obvious question is which quality setting to turn up.

For aifaceswap-face-swap, there isn't a verified one.

That puts most of the practical control back on the images. The current model guidance recommends high-quality inputs with clear faces and compatible lighting, and warns about harsh shadows, low resolution, and extreme angles.

Input condition Why it matters
Face is clearly visible Gives the model usable facial detail
Adequate face resolution Small or blurry faces contain less identity information
Similar orientation Reduces the pose mismatch the swap has to bridge
Compatible lighting Makes the transferred face less visually disconnected
Minimal occlusion Hands, hair, or objects can hide facial regions
Avoid extreme shadow or angle Both are called out as difficult conditions by the model guidance

You do not need to reject every imperfect photograph. Consumer photos are imperfect by default. Catch the obvious problems early and give the user a useful correction instead.

Canberk Sinangil's POV

“A demo distribution is clean. A consumer-photo distribution is chaos.”

Real users move several variables at once: lighting, pose, occlusion, camera quality, and background. Production quality therefore cannot be treated as a model-only problem. Input validation, failure states, and telemetry around what users actually submit matter too.

If your own validation layer can tell that a face is tiny in the frame, ask for a closer photo before paying for generation. If the face is heavily obstructed or the image is extremely dark, a warning may be enough.

Those checks should not masquerade as an aesthetic score. A difficult image can still work. A technically clean one can still produce a result the user rejects.

Moderate before generation when moderation controls whether generation is allowed

If your product has rules about what users may upload, apply them before generation when you can.

each::labs exposes a separate NSFW Image Detection model:

nsfw-image-detection

Its request is:

{
  "model": "nsfw-image-detection",
  "input": {
    "image": "https://example.com/upload.jpg"
  }
}

The current model page demonstrates a text output such as:

normal

The detector can sit in front of user-generated content processing, but it is not a substitute for product policy. Ambiguous or low-quality images can be classified incorrectly, and the model does not know the user's intent, the surrounding context, or the rules of your application.

A moderation signal is not your product policy

The code path might be as simple as:

classification = moderate(image_url)

if classification == "normal":
    proceed()
else:
    reject_or_review()

allow, reject, and review are application decisions. They are not response values being attributed to the moderation model.

That separation matters because the detector has no way to know whether the media will stay private, appear on a public feed, or enter a feature with stricter rules. Your application has to turn the classification into a policy decision.

The helper below therefore handles only the response shape we can actually verify:

def moderate_image(api_key: str, image_url: str) -> str:
    output = run_model(
        api_key,
        "nsfw-image-detection",
        {"image": image_url},
    )

    if not isinstance(output, str):
        raise RuntimeError(
            "The current NSFW model page demonstrates a text label output, "
            f"but this execution returned {type(output).__name__}."
        )

    return output.strip().lower()

The same model page refers to confidence scores elsewhere in its prose, but it does not expose their field names or response shape. This integration does not guess.

Should you moderate the output too?

Sometimes.

Input moderation asks whether submitted media is allowed into the generation path. The result is new media, so a product that publishes or redistributes that result may want another policy check before it goes any further.

A private editing tool and a public social surface have different exposure. There is no universal requirement to run both gates. The useful architectural point is simply that output moderation is a separate decision from the face-swap call.

Consent is a signature, not a setting.
Consent is a signature, not a setting.

An image can pass an NSFW classifier while the uploader has no permission to use the person's likeness.

The opposite is also possible: the uploader may be authorized to use the face while the media still violates your product's content rules.

Control Question it answers What it cannot establish
Moderation Does this media comply with our content policy? Whether the person authorized use of their likeness
Consent Does our application have the authorization it requires for this likeness use? Whether the media complies with the content policy

A moderation classifier cannot establish consent.

The current aifaceswap-face-swap request has no consent or identity-verification field. The model page recommends obtaining consent, but the model itself does not validate that state.

Your application needs to establish whatever authorization state the product and applicable requirements call for before inference.

In the integration, that can be represented as an application-side reference:

No valid consent reference
          ↓
Stop before upload / inference

Valid consent reference
          ↓
Continue to moderation and generation

That reference is not an each::labs parameter. It is your application's record that its own consent step has completed.

The technical boundary is clearer than the legal one. This guide is not claiming that one checkbox, database field, or interaction is sufficient for every product or jurisdiction.

A working face swap flow in Python

Now the pieces can be connected.

The complete server-side example:

  1. requires an application consent reference;
  2. uploads the source image;
  3. uploads the face image;
  4. moderates both inputs;
  5. stops if the example policy rejects either image;
  6. runs aifaceswap-face-swap;
  7. polls until a terminal result;
  8. optionally moderates the generated image;
  9. deletes the original uploads in a finally block.

Install the dependency:

pip install requests

Set the credentials and your application's consent-record reference outside the source:

export EACHLABS_API_KEY="your-api-key"
export FACE_SWAP_CONSENT_REFERENCE="your-app-record-id"

Then:

import mimetypes
import os
import time
from pathlib import Path
from typing import Any

import requests


API_BASE = "https://api.eachlabs.ai/v1"

IN_PROGRESS = {
    "created",
    "starting",
    "processing",
}

# Current machine-readable docs use "error".
# Some current human-readable docs still use "failed".
FAILURE_STATES = {
    "error",
    "failed",
    "cancelled",
}


class ModerationRejected(Exception):
    pass


class ConsentRequired(Exception):
    pass


def api_headers(api_key: str) -> dict[str, str]:
    return {
        "Authorization": f"Bearer {api_key}",
    }


def upload_image(api_key: str, file_path: str) -> dict[str, str]:
    path = Path(file_path)

    content_type, _ = mimetypes.guess_type(path.name)
    if not content_type or not content_type.startswith("image/"):
        raise ValueError(f"Unsupported image type: {path}")

    response = requests.post(
        f"{API_BASE}/upload/presign",
        headers={
            **api_headers(api_key),
            "Content-Type": "application/json",
        },
        json={
            "content_type": content_type,
            "file_type": "image",
        },
        timeout=30,
    )
    response.raise_for_status()
    upload = response.json()

    with path.open("rb") as file:
        put_response = requests.put(
            upload["presigned_url"],
            data=file,
            headers={
                "Content-Type": content_type,
                **(upload.get("required_headers") or {}),
            },
            timeout=120,
        )
        put_response.raise_for_status()

    return {
        "id": upload["id"],
        "public_url": upload["public_url"],
    }


def delete_upload(api_key: str, file_id: str) -> None:
    response = requests.delete(
        f"{API_BASE}/files/{file_id}",
        headers=api_headers(api_key),
        timeout=30,
    )

    if response.status_code == 404:
        return

    response.raise_for_status()


def create_prediction(
    api_key: str,
    model: str,
    input_data: dict[str, Any],
) -> str:
    response = requests.post(
        f"{API_BASE}/prediction",
        headers={
            **api_headers(api_key),
            "Content-Type": "application/json",
        },
        json={
            "model": model,
            "input": input_data,
        },
        timeout=30,
    )
    response.raise_for_status()

    payload = response.json()
    return payload["predictionID"]


def wait_for_prediction(
    api_key: str,
    prediction_id: str,
    poll_interval: float = 2.0,
    max_wait_seconds: float = 180.0,
) -> dict[str, Any]:
    deadline = time.monotonic() + max_wait_seconds

    while time.monotonic() < deadline:
        response = requests.get(
            f"{API_BASE}/prediction/{prediction_id}",
            headers=api_headers(api_key),
            timeout=30,
        )
        response.raise_for_status()

        prediction = response.json()
        status = prediction["status"]

        if status == "success":
            return prediction

        if status in FAILURE_STATES:
            detail = prediction.get("logs") or "no details returned"
            raise RuntimeError(
                f"Prediction {prediction_id} ended with "
                f"status={status}: {detail}"
            )

        if status not in IN_PROGRESS:
            raise RuntimeError(
                f"Prediction {prediction_id} returned "
                f"an unexpected status: {status}"
            )

        time.sleep(poll_interval)

    raise TimeoutError(
        f"Prediction {prediction_id} did not finish within "
        f"{max_wait_seconds} seconds"
    )


def run_model(
    api_key: str,
    model: str,
    input_data: dict[str, Any],
) -> Any:
    prediction_id = create_prediction(
        api_key=api_key,
        model=model,
        input_data=input_data,
    )

    prediction = wait_for_prediction(
        api_key=api_key,
        prediction_id=prediction_id,
    )

    return prediction.get("output")


def moderate_image(api_key: str, image_url: str) -> str:
    output = run_model(
        api_key=api_key,
        model="nsfw-image-detection",
        input_data={
            "image": image_url,
        },
    )

    if not isinstance(output, str):
        raise RuntimeError(
            "The current NSFW model page demonstrates a text label "
            f"output, but this execution returned "
            f"{type(output).__name__}."
        )

    return output.strip().lower()


def require_allowed_image(
    api_key: str,
    image_url: str,
) -> None:
    classification = moderate_image(api_key, image_url)

    # Example application policy.
    # This is not a list of every possible moderation label.
    if classification != "normal":
        raise ModerationRejected(
            "Image rejected by example application policy: "
            f"{classification}"
        )


def find_first_http_url(value: Any) -> str | None:
    """
    The generic prediction contract allows string, array, or object
    outputs. The current face-swap page does not expose a more specific
    result envelope, so find a media URL without assuming a field name.
    """
    if isinstance(value, str):
        if value.startswith(("http://", "https://")):
            return value
        return None

    if isinstance(value, list):
        for item in value:
            found = find_first_http_url(item)
            if found:
                return found

    if isinstance(value, dict):
        for item in value.values():
            found = find_first_http_url(item)
            if found:
                return found

    return None


def create_consumer_face_swap(
    api_key: str,
    source_path: str,
    face_path: str,
    *,
    consent_reference: str,
    moderate_output: bool = True,
) -> str:
    if not consent_reference.strip():
        raise ConsentRequired(
            "Application consent state must be established "
            "before face-swap inference."
        )

    uploads: list[dict[str, str]] = []

    try:
        source = upload_image(api_key, source_path)
        uploads.append(source)

        face = upload_image(api_key, face_path)
        uploads.append(face)

        require_allowed_image(
            api_key,
            source["public_url"],
        )
        require_allowed_image(
            api_key,
            face["public_url"],
        )

        output = run_model(
            api_key=api_key,
            model="aifaceswap-face-swap",
            input_data={
                "source_image": source["public_url"],
                "face_image": face["public_url"],
            },
        )

        result_url = find_first_http_url(output)

        if not result_url:
            raise RuntimeError(
                "Face swap succeeded, but no HTTP(S) media URL "
                "was found in the prediction output."
            )

        if moderate_output:
            require_allowed_image(
                api_key,
                result_url,
            )

        return result_url

    finally:
        for upload in uploads:
            try:
                delete_upload(
                    api_key,
                    upload["id"],
                )
            except requests.RequestException as exc:
                # Send this to a real cleanup/logging path in production.
                print(
                    "Warning: failed to delete upload "
                    f"{upload['id']}: {exc}"
                )


if __name__ == "__main__":
    api_key = os.environ["EACHLABS_API_KEY"]
    consent_reference = os.environ[
        "FACE_SWAP_CONSENT_REFERENCE"
    ]

    result = create_consumer_face_swap(
        api_key=api_key,
        source_path="source.jpg",
        face_path="face.jpg",
        consent_reference=consent_reference,
    )

    print(result)

Three conservative decisions in that code are intentional.

consent_reference stays outside the each::labs request because it represents your application's own authorization state. The API does not create or verify it.

The sample moderation policy permits only the currently demonstrated normal label. That is an example policy, not a claim that every other classifier output must always be blocked. Production policy should be based on the classifications you actually observe and the rules your product needs.

Finally, the URL extractor does not pretend that we have a face-swap-specific result envelope. The generic prediction contract permits different output shapes, while the current face-swap page does not expose an authoritative JSON response structure. If a model-specific output contract becomes available, use that instead of the defensive parser.

Why this example polls instead of using a webhook

The current webhook documentation conflicts with itself. Human-readable API docs describe model prediction webhooks, while the machine-readable OpenAPI contract says webhook support is currently limited to Workflows V2.

Polling has a clearer contract:

GET /v1/prediction/{id}

The current machine-readable API uses created, starting, and processing while a job is active, then success, error, or cancelled as terminal states. Some human-readable docs still use failed, so the example accepts both failure spellings while the documentation catches up.

In a consumer product, run that polling loop in backend infrastructure. The UI does not need to hold one HTTP request open for the full generation.

Treat an uploaded face as something you must be able to delete.
Treat an uploaded face as something you must be able to delete.

Treat uploaded faces as temporary product data

When the swap finishes, decide what media you still need.

The upload flow gives you a file ID, and the API exposes:

DELETE /v1/files/{id}

A successful deletion returns 204; the current contract also documents 404 when the file is absent and 409 when it cannot be deleted in its current state.

That is why the code preserves both values:

{
    "id": upload["id"],
    "public_url": upload["public_url"],
}

The URL is useful for inference. The ID gives you a cleanup handle.

Do not stretch that into a blanket claim that every face-swap request is zero-retention. Current first-party pages describe different retention behavior in different contexts, and they are not specific enough to support one promise for every account and request.

Your product still needs its own answers:

  • Which uploaded media do we need after this task?
  • How long do we need it?
  • Which debugging information can we keep as metadata instead?

Canberk Sinangil's POV

“'It makes debugging easier' is how user faces end up in a bucket nobody owns.”

Losing the media makes some incidents harder to investigate later. That is a real cost. The answer is stronger metadata—trace IDs, response metadata, timings, input shape, and failure class—not quietly retaining personal media because opening the original file is convenient.

Faces should not become permanent debug artifacts by accident.

Handle policy rejection differently from model failure

A moderation rejection is not an inference outage.

If your application decides that an image is not permitted, retrying the same content does not turn that policy result into an infrastructure problem.

Event Typical application behavior
Input violates policy Reject or review; do not regenerate
Retryable HTTP/infrastructure problem Retry cautiously if appropriate
Prediction ends in a failure state Surface or handle the model failure
Prediction is cancelled Treat it as a terminal job
Generated output violates policy Withhold or review according to product policy

Canberk Sinangil's POV

“The semantics have to come before the pattern.”

Retrying a read and retrying a generation are different operations. Another generation can mean another paid inference and a different artifact. Decide which failures are actually transient before wrapping the whole path in generic retry logic.

For retryable 429 or 5xx responses, respect Retry-After when the API provides it. Otherwise, bounded exponential backoff is the safer default.

When you need a workflow instead of direct calls

The implementation above uses normal backend application code. For this feature, that may be all you need.

If the product has one face-swap model, one moderation call, and a few branches, moving everything into a workflow layer can add abstraction without removing much complexity.

A workflow starts earning its place when the system already coordinates several model calls, repeated moderation gates, branching, fallbacks, or shared execution logic across multiple product surfaces.

The useful question is not whether the diagram can become a workflow. Almost anything can.

The question is whether the layer removes complexity you already have.

For broader multi-model systems, each::labs exposes each::workflows alongside direct each::api calls. If that is the problem you are solving, the broader each::labs guide to lipsync, face swap, moderation, and video APIs covers that orchestration layer. This face-swap integration does not require it.

Face swap API FAQ

What inputs does AI Face Swap V1 need?

The current request exposes source_image and face_image. The source image is the scene being edited; the face image contains the identity to transfer.

Does AI Face Swap V1 require a prompt?

No. The current request is image-based and exposes no text prompt.

Is there a face swap quality setting?

No quality, restoration, seed, strength, or resolution field is verified in the current callable example. Input clarity, resolution, lighting, pose, and occlusion are the practical controls to address first.

Should I moderate images before face swapping?

If moderation determines whether generation is allowed, screen the inputs first. That gives the application a chance to stop before generating content it will immediately reject.

Should I moderate the generated result?

Some products should. The generated image is new media, so a product that publishes or redistributes it may want another policy check. The need depends on the product's exposure and moderation rules.

No. Moderation asks whether media complies with a content policy. Consent asks whether the application has the authorization it requires to use a person's likeness. A moderation classifier cannot establish that authorization.

No consent or identity-verification field appears in the current aifaceswap-face-swap request. Consent has to be handled by the application.

Does AI Face Swap V1 support video?

No. The version covered here is the current still-image AI Face Swap V1 flow.

Should I use webhooks or polling?

This guide uses polling because the current webhook documentation conflicts. GET /v1/prediction/{id} is the consistently established status path.

The two-image face-swap request is the smallest piece of the feature. Consent controls whether your application should accept the likeness use. Moderation controls whether the media fits your product rules. Upload and cleanup logic decide what happens to the files around the model call.

Canberk Sinangil

Co-founder & CTO

I’m the Co-Founder and CTO of each::labs, where I focus on building the infrastructure and tooling that help developers bring AI models into production. My background spans computer vision, augmented reality, machine learning, and software engineering, including building AR and visual AI products before moving deeper into generative AI. I’m particularly interested in the engineering challenges behind making powerful AI models fast, scalable, and practical for real-world products.

LinkedIn X

Build the generation, moderation, and workflow path your product needs.

Build your Workflow on each::labs