DEV Community

Cover image for Build an AI Image Detector API with C2PA + a Classifier
Hassann
Hassann

Posted on • Originally published at apidog.com

Build an AI Image Detector API with C2PA + a Classifier

Someone uploads a photo to your product and says it came from a camera. Can your backend verify that claim? Modern image generators can produce images that look real to human reviewers, so visual inspection is not enough. A practical backend can combine two independent signals: a cryptographic provenance manifest and a machine-learning classifier. Together, they produce a more honest verdict than either signal alone.

Try Apidog today

This guide shows how to build a single FastAPI service with a POST /verify endpoint. The endpoint accepts an image upload and returns a JSON verdict with a confidence score, provenance details, and classifier output. You will use Python, FastAPI, open-source C2PA tooling for provenance, and a hosted detection API for the classifier. Because this is an API project, you will also design the endpoint contract first and use Apidog to mock and test it before the backend is complete.

TL;DR

You will build a FastAPI service exposing POST /verify that:

  1. Accepts an uploaded image.
  2. Extracts and validates its C2PA Content Credentials manifest with c2pa-python.
  3. Calls a hosted AI-image detection classifier.
  4. Combines both signals into one JSON verdict:
    • likely_authentic
    • likely_ai
    • uncertain
  5. Returns a confidence score and the raw signal details.
  6. Uses an OpenAPI schema in Apidog to generate mocks and run endpoint tests.

Why use two signals?

There is no single file property that proves an image was made by a human or generated by AI. You need multiple clues.

Signal 1: C2PA provenance

C2PA, the Coalition for Content Provenance and Authenticity, is an open standard for attaching tamper-evident, cryptographically signed metadata to media files.

That metadata bundle is called a manifest. The user-facing name is Content Credentials.

A participating camera, editor, or image generator can write a manifest that records what happened to the image and signs it with a certificate. If your backend can read and validate that manifest, it gets a strong statement about the image’s history.

The limitation: C2PA is opt-in and easy to strip.

A missing manifest can happen because:

  • the image was screenshotted,
  • a messaging app re-encoded it,
  • a platform removed metadata,
  • the original tool never wrote Content Credentials.

So, no manifest does not mean the image is fake. It also does not mean it is real.

Signal 2: AI-image classifier

A classifier analyzes the pixels and returns a probability that the image is AI-generated.

It works even when metadata is missing, but it is probabilistic. A score is not proof. It can be wrong, especially for compressed images, edited images, or images outside the model’s training distribution.

Why combine them?

Provenance is precise but often absent. A classifier is broadly available but uncertain.

The goal is to return a verdict that says:

Here is what the cryptography proves, here is what the model estimates, and here is how confident the combination makes us.

For more background on single-signal failure modes, see why AI image detection fails.

Architecture overview

The service has one endpoint, two signal processors, and one combined response.

                ┌─────────────────────────────┐
   image  ──▶   │   FastAPI  POST /verify      │
                │                              │
                │   1. validate upload         │
                │   2. ┌──────────────────┐    │
                │      │ C2PA manifest     │    │  provenance signal
                │      │ c2pa-python       │    │
                │      └──────────────────┘    │
                │   3. ┌──────────────────┐    │
                │      │ classifier API    │    │  statistical signal
                │      │ hosted detector   │    │
                │      └──────────────────┘    │
                │   4. combine into verdict    │
                └─────────────────────────────┘
                              │
                              ▼
                   JSON verdict + confidence
Enter fullscreen mode Exit fullscreen mode

The request flow:

  1. Validate the upload type and size.
  2. Read the C2PA manifest locally.
  3. Send the image bytes to a hosted classifier over HTTPS.
  4. Merge both signals into one verdict.

The two signal steps are independent. If the classifier times out, the endpoint can still return a partial verdict from provenance. If no manifest exists, the classifier can still provide a score.

Install the dependencies:

pip install fastapi "uvicorn[standard]" python-multipart httpx c2pa-python
Enter fullscreen mode Exit fullscreen mode

You need Python 3.10 or newer because the C2PA library requires it.

Implement the C2PA provenance signal

The Content Authenticity Initiative publishes open-source C2PA tooling under the contentauth GitHub organization.

You will commonly see two tools:

  • c2patool: a CLI for displaying and adding manifests. Its standalone repository is archived, and the CLI now lives inside the c2pa-rs Rust project.
  • c2pa-python: Python bindings for the same underlying Rust library, c2pa-rs. This is what the API service uses.

The read path centers on a Reader object. Point it at an image file, then read the manifest store as JSON.

# provenance.py
import json
import c2pa


def read_provenance(image_path: str) -> dict:
    """
    Read and validate the C2PA manifest from an image.
    Returns a normalized dict describing what was found.
    """
    try:
        with c2pa.Reader(image_path) as reader:
            manifest_store = json.loads(reader.json())
    except c2pa.C2paError as err:
        # ManifestNotFound is expected for most images.
        if str(err).startswith("ManifestNotFound"):
            return {
                "has_manifest": False,
                "validation": "none",
                "detail": "No C2PA manifest present in this image.",
            }

        # Other C2PA errors mean the file had C2PA data we could not parse.
        return {
            "has_manifest": True,
            "validation": "error",
            "detail": f"Could not parse manifest: {err}",
        }

    active_label = manifest_store.get("active_manifest")
    manifests = manifest_store.get("manifests", {})
    active = manifests.get(active_label, {})

    # validation_status appears only when there are validation problems.
    validation_status = manifest_store.get("validation_status", [])
    validation = "valid" if not validation_status else "invalid"

    claim_generator = active.get("claim_generator", "unknown")
    signature_issuer = active.get("signature_info", {}).get("issuer", "unknown")

    return {
        "has_manifest": True,
        "validation": validation,
        "claim_generator": claim_generator,
        "signature_issuer": signature_issuer,
        "validation_status": validation_status,
        "detail": "Manifest read successfully.",
    }
Enter fullscreen mode Exit fullscreen mode

Key implementation details:

  • Use Reader as a context manager so resources are released.
  • reader.json() returns the full manifest store as a JSON string.
  • reader.detailed_json() is available if you need a longer report with every assertion and ingredient.
  • Treat ManifestNotFound as normal data, not an error.
  • Check validation_status. An empty array means the manifest validated. A populated array means validation failed.

For verdicts, the most useful fields are:

  • claim_generator: the tool that wrote the manifest.
  • signature_issuer: the certificate issuer.
  • validation_status: validation problems, if any.

This signal cannot help when no manifest exists. That is why the classifier signal is required.

Implement the classifier signal

The classifier is a hosted API that scores the image’s likelihood of being AI-generated.

This example uses Sightengine because its AI-detection model has a documented HTTP API and a clear response shape. The same pattern works with other providers: change the URL, parameters, and response field.

If you are comparing providers, see this roundup of the best AI image detection APIs.

Sightengine’s endpoint is:

https://api.sightengine.com/1.0/check.json
Enter fullscreen mode Exit fullscreen mode

You send:

  • media: the uploaded image file
  • models: genai
  • api_user: your API user
  • api_secret: your API secret

The response includes:

{
  "type": {
    "ai_generated": 0.91
  }
}
Enter fullscreen mode Exit fullscreen mode

The type.ai_generated value is a score from 0 to 1, where higher means more likely AI-generated.

# classifier.py
import httpx

SIGHTENGINE_URL = "https://api.sightengine.com/1.0/check.json"


async def classify_image(
    image_bytes: bytes,
    filename: str,
    api_user: str,
    api_secret: str,
    timeout_seconds: float = 8.0,
) -> dict:
    """
    Send the image to the hosted detector.
    Returns a normalized dict with the AI-generated score.
    """
    data = {
        "models": "genai",
        "api_user": api_user,
        "api_secret": api_secret,
    }
    files = {"media": (filename, image_bytes)}

    try:
        async with httpx.AsyncClient(timeout=timeout_seconds) as client:
            response = await client.post(SIGHTENGINE_URL, data=data, files=files)
            response.raise_for_status()
            payload = response.json()
    except httpx.TimeoutException:
        return {"available": False, "reason": "classifier_timeout"}
    except httpx.HTTPStatusError as err:
        return {
            "available": False,
            "reason": f"classifier_http_{err.response.status_code}",
        }
    except httpx.HTTPError as err:
        return {"available": False, "reason": f"classifier_error: {err}"}

    if payload.get("status") != "success":
        return {
            "available": False,
            "reason": payload.get("error", {}).get("message", "unknown_error"),
        }

    ai_score = payload.get("type", {}).get("ai_generated")
    if ai_score is None:
        return {"available": False, "reason": "missing_score_in_response"}

    return {"available": True, "ai_score": float(ai_score)}
Enter fullscreen mode Exit fullscreen mode

This function is async so a slow classifier call does not block the event loop.

Failure paths return:

{
  "available": false,
  "reason": "classifier_timeout"
}
Enter fullscreen mode Exit fullscreen mode

Do not raise an exception for normal classifier failures. A third-party outage should degrade the verdict, not crash the request.

Treat the score as an estimate. A score like 0.92 means the model is confident, not that AI generation has been proven. For practical detection workflows, see how to check if an image is AI generated.

Design the /verify API contract first

Before writing the route handler, define the request and response shape.

This is where Apidog is useful. A contract-first workflow gives you:

  • one agreed schema for backend and frontend,
  • a mock server before backend implementation,
  • reusable endpoint tests once the real API exists.

Request

POST /verify accepts multipart/form-data with one field:

Field Type Required Description
image File Yes JPEG, PNG, or WebP image to verify

Keep the request simple. Add optional query parameters later only if needed.

Response

Return the final verdict, confidence, raw signals, explanation, and timestamp.

{
  "verdict": "likely_ai",
  "confidence": 0.86,
  "signals": {
    "provenance": {
      "has_manifest": true,
      "validation": "valid",
      "claim_generator": "SomeImageTool/2.1",
      "signature_issuer": "Some Issuing CA"
    },
    "classifier": {
      "available": true,
      "ai_score": 0.91
    }
  },
  "explanation": "A valid C2PA manifest names an AI image tool, and the classifier scored the image as likely AI-generated.",
  "checked_at": "2026-05-21T09:30:00Z"
}
Enter fullscreen mode Exit fullscreen mode

Use three verdict values:

  • likely_authentic
  • likely_ai
  • uncertain

Do not force a binary result. If the signals conflict or are weak, uncertain is the correct answer.

Here is the OpenAPI response component:

components:
  schemas:
    VerifyResponse:
      type: object
      required: [verdict, confidence, signals, checked_at]
      properties:
        verdict:
          type: string
          enum: [likely_authentic, likely_ai, uncertain]
        confidence:
          type: number
          format: float
          minimum: 0
          maximum: 1
        signals:
          type: object
          properties:
            provenance:
              type: object
              properties:
                has_manifest:
                  type: boolean
                validation:
                  type: string
                  enum: [valid, invalid, error, none]
                claim_generator:
                  type: string
                signature_issuer:
                  type: string
            classifier:
              type: object
              properties:
                available:
                  type: boolean
                ai_score:
                  type: number
                  format: float
        explanation:
          type: string
        checked_at:
          type: string
          format: date-time
Enter fullscreen mode Exit fullscreen mode

You can author this in Apidog’s visual designer or import an existing OpenAPI file. For a full contract-first workflow, see the spec-first mode walkthrough.

Combine the two signals

The combining function encodes your policy.

A valid manifest is the stronger signal because it is cryptographic. The classifier is a fallback and a conflict detector.

# verdict.py


def combine_signals(provenance: dict, classifier: dict) -> dict:
    """Merge the provenance and classifier signals into one verdict."""
    has_manifest = provenance.get("has_manifest", False)
    validation = provenance.get("validation", "none")
    generator = (provenance.get("claim_generator") or "").lower()

    classifier_ok = classifier.get("available", False)
    ai_score = classifier.get("ai_score")

    # Heuristic: known AI tools tend to identify themselves in the manifest.
    ai_keywords = (
        "firefly",
        "dall-e",
        "dalle",
        "midjourney",
        "stable",
        "gpt",
        "gemini",
        "imagen",
        "generat",
    )
    generator_looks_ai = any(k in generator for k in ai_keywords)

    # Case 1: valid manifest naming an AI generator.
    if has_manifest and validation == "valid" and generator_looks_ai:
        return _verdict(
            "likely_ai",
            0.95,
            "A valid C2PA manifest names an AI image tool.",
        )

    # Case 2: valid manifest from a camera or non-AI editor.
    if has_manifest and validation == "valid" and not generator_looks_ai:
        if classifier_ok and ai_score is not None and ai_score > 0.85:
            return _verdict(
                "uncertain",
                0.55,
                "Manifest looks authentic but the classifier disagrees; signals conflict.",
            )

        return _verdict(
            "likely_authentic",
            0.9,
            "A valid C2PA manifest from a non-AI tool is present.",
        )

    # Case 3: manifest exists but fails validation.
    if has_manifest and validation in ("invalid", "error"):
        return _verdict(
            "uncertain",
            0.6,
            "The image carries a C2PA manifest that failed validation; its claimed history is unverified.",
        )

    # Case 4: no manifest. Use the classifier.
    if classifier_ok and ai_score is not None:
        if ai_score >= 0.7:
            return _verdict(
                "likely_ai",
                round(ai_score, 2),
                "No provenance data; the classifier scored the image as likely AI-generated.",
            )

        if ai_score <= 0.3:
            return _verdict(
                "likely_authentic",
                round(1 - ai_score, 2),
                "No provenance data; the classifier scored the image as likely authentic.",
            )

        return _verdict(
            "uncertain",
            0.5,
            "No provenance data and the classifier score is inconclusive.",
        )

    # Case 5: no manifest and no classifier.
    return _verdict(
        "uncertain",
        0.0,
        "No provenance data and the classifier was unavailable.",
    )


def _verdict(verdict: str, confidence: float, explanation: str) -> dict:
    return {
        "verdict": verdict,
        "confidence": confidence,
        "explanation": explanation,
    }
Enter fullscreen mode Exit fullscreen mode

This policy is intentionally conservative:

  • A valid AI-generator manifest returns likely_ai.
  • A valid non-AI manifest returns likely_authentic unless the classifier strongly disagrees.
  • A failed manifest returns uncertain.
  • No manifest falls back to the classifier.
  • No signal returns uncertain with 0.0 confidence.

Tune the thresholds for your risk profile. A newsroom, marketplace, and insurance platform may choose different cutoffs.

Build the FastAPI endpoint

Now wire the modules into one route.

# main.py
import os
import tempfile
from datetime import datetime, timezone

from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.responses import JSONResponse

from provenance import read_provenance
from classifier import classify_image
from verdict import combine_signals

app = FastAPI(title="AI Image Detector API", version="1.0.0")

ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
MAX_BYTES = 12 * 1024 * 1024  # 12 MB

SIGHTENGINE_USER = os.environ.get("SIGHTENGINE_API_USER", "")
SIGHTENGINE_SECRET = os.environ.get("SIGHTENGINE_API_SECRET", "")


@app.post("/verify")
async def verify(image: UploadFile = File(...)):
    # 1. Validate content type.
    if image.content_type not in ALLOWED_TYPES:
        raise HTTPException(
            status_code=415,
            detail=(
                f"Unsupported type {image.content_type}. "
                "Send JPEG, PNG, or WebP."
            ),
        )

    image_bytes = await image.read()

    if len(image_bytes) == 0:
        raise HTTPException(status_code=400, detail="Empty file.")

    if len(image_bytes) > MAX_BYTES:
        raise HTTPException(status_code=413, detail="File exceeds 12 MB limit.")

    # 2. Read C2PA provenance.
    # c2pa-python expects a file path, so write a temporary file.
    suffix = os.path.splitext(image.filename or "")[1] or ".img"

    with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
        tmp.write(image_bytes)
        tmp_path = tmp.name

    try:
        provenance = read_provenance(tmp_path)
    finally:
        os.unlink(tmp_path)

    # 3. Call classifier.
    # Failures return available: false instead of raising.
    if SIGHTENGINE_USER and SIGHTENGINE_SECRET:
        classifier = await classify_image(
            image_bytes=image_bytes,
            filename=image.filename or "upload",
            api_user=SIGHTENGINE_USER,
            api_secret=SIGHTENGINE_SECRET,
        )
    else:
        classifier = {
            "available": False,
            "reason": "classifier_not_configured",
        }

    # 4. Combine signals.
    result = combine_signals(provenance, classifier)

    return JSONResponse(
        {
            "verdict": result["verdict"],
            "confidence": result["confidence"],
            "signals": {
                "provenance": {
                    k: provenance.get(k)
                    for k in (
                        "has_manifest",
                        "validation",
                        "claim_generator",
                        "signature_issuer",
                    )
                },
                "classifier": {
                    "available": classifier.get("available", False),
                    "ai_score": classifier.get("ai_score"),
                },
            },
            "explanation": result["explanation"],
            "checked_at": datetime.now(timezone.utc).isoformat(),
        }
    )
Enter fullscreen mode Exit fullscreen mode

Run the API locally:

uvicorn main:app --reload
Enter fullscreen mode Exit fullscreen mode

The endpoint is available at:

http://127.0.0.1:8000/verify
Enter fullscreen mode Exit fullscreen mode

The handler writes the uploaded image to a temporary file because the C2PA reader needs a path. It deletes the file in a finally block. The classifier uses the image bytes directly.

The endpoint does not crash when:

  • the manifest is missing,
  • the classifier is unavailable,
  • credentials are not configured.

Those are normal states handled by the verdict function.

This kind of focused API service fits the broader pattern of products exposing core capabilities through APIs. For more on that shift, see software going headless.

Mock and test the API with Apidog

A frontend team should not need to wait for Sightengine credentials, C2PA edge cases, or deployment before building the upload UI.

Use the OpenAPI schema to mock the endpoint first.

Generate a mock server

In Apidog:

  1. Create or import the OpenAPI schema.
  2. Define POST /verify.
  3. Add the multipart/form-data request body with the image file field.
  4. Add the VerifyResponse schema.
  5. Generate a mock server.

Because the schema defines enums and field types, the mock can return realistic data:

{
  "verdict": "uncertain",
  "confidence": 0.5,
  "signals": {
    "provenance": {
      "has_manifest": false,
      "validation": "none"
    },
    "classifier": {
      "available": true,
      "ai_score": 0.48
    }
  },
  "explanation": "No provenance data and the classifier score is inconclusive.",
  "checked_at": "2026-05-21T09:30:00Z"
}
Enter fullscreen mode Exit fullscreen mode

Create example responses for important states:

  • likely_authentic with a valid camera manifest,
  • likely_ai with an AI tool named in the manifest,
  • uncertain when the classifier is unavailable,
  • 413 for oversized uploads,
  • 415 for unsupported media types.

The frontend can build every result state against the mock. When the real backend ships, change the base URL.

Run endpoint tests

Once the backend is running:

  1. Create a request in Apidog for POST /verify.
  2. Set the URL to your local backend.
  3. In the Body tab, choose form-data.
  4. Add an image field.
  5. Set its type to File.
  6. Upload a test image.
  7. Send the request.

Add assertions:

  • response status is 200,
  • verdict exists,
  • verdict is one of likely_authentic, likely_ai, or uncertain,
  • confidence is a number between 0 and 1,
  • signals.provenance.has_manifest is a boolean.

Then create a small test scenario with multiple files:

  • an image with Content Credentials,
  • a plain JPEG with no manifest,
  • an oversized file,
  • a non-image file renamed to .jpg.

Each test exercises a different branch of validation and verdict logic.

Hardening and edge cases

A verification endpoint receives untrusted input. Handle the uncomfortable cases explicitly.

Corrupt or truncated files

A file can claim to be an image and still be invalid.

The C2PA reader raises C2paError when it cannot parse data, and read_provenance() already converts that into a clean result. For stronger validation, decode the image with Pillow before processing and reject invalid files with 400.

Missing manifest

This is the common case.

No manifest is:

  • not an error,
  • not proof of AI generation,
  • not proof of authenticity.

Catch ManifestNotFound and continue to the classifier.

Classifier timeout or outage

The classifier is a network dependency. Assume it will fail.

Use an explicit timeout and return:

{
  "available": false,
  "reason": "classifier_timeout"
}
Enter fullscreen mode Exit fullscreen mode

The endpoint should still return a 200 response with an honest uncertain verdict when no other useful signal exists.

Spoofed or tampered manifests

A manifest can be present but invalid. It may be signed with a bad certificate or contain hashes that do not match the pixels.

Always inspect validation_status.

  • Empty array: manifest verified.
  • Populated array: manifest failed validation.

Never trust an unvalidated manifest.

Large files and abuse

Set an upload size limit. The example uses 12 MB.

Also consider:

  • API rate limiting,
  • authentication for internal use,
  • request body limits at the reverse proxy,
  • cost controls for third-party classifier calls.

Privacy

You are processing user images.

At minimum:

  • delete temporary files immediately,
  • do not log image bytes,
  • avoid storing uploads unless your product requires it,
  • disclose third-party classifier processing in your privacy policy.

What each signal catches and misses

Scenario C2PA provenance signal Classifier signal
AI image from a tool that writes Content Credentials Catches it: manifest names the generator Usually catches it: artifacts present
AI image with metadata stripped, such as screenshot or re-upload Misses it: no manifest to read Catches it: works on pixels
Real photo from a camera that signs Content Credentials Confirms it: valid manifest, non-AI generator May false-positive on heavy compression or edits
Real photo with no metadata No signal Best guess only
Image with a forged or tampered manifest Catches it: validation_status flags the failure May or may not catch it
Novel generator the classifier was not trained on Catches it only if the tool writes a manifest Often misses it
Heavily edited real photo with AI retouching Manifest may record edit history Ambiguous; score may land mid-range

The pattern is consistent: provenance is exact but sparse; the classifier is broad but fuzzy. The combined verdict is more useful than either signal alone.

Real-world use cases

This pattern works anywhere you need a fast first-pass assessment.

User-generated content platforms

Run uploads through /verify and route results:

  • likely_authentic: allow,
  • likely_ai: label or queue for review,
  • uncertain: send to moderation.

Newsrooms and fact-checking

Editors can get provenance data and a classifier estimate in one call, with a human-readable explanation for notes.

Insurance and claims intake

A verification step can flag images that look generated or carry invalid manifests before a human adjuster reviews them.

Internal asset pipelines

Teams can gate stock-library ingestion on /verify and require AI-generated images to be labeled.

Provenance-aware publishing

As more cameras and editors adopt Content Credentials, a CMS can show verified provenance badges and fall back to classifier output when no manifest is present.

Conclusion

Reliable AI-image detection is not about one perfect test. It is about combining independent signals and reporting uncertainty clearly.

You built a service that:

  • reads C2PA Content Credentials,
  • calls a hosted AI-image classifier,
  • combines both into likely_authentic, likely_ai, or uncertain,
  • returns confidence and raw signals,
  • exposes a clean POST /verify API,
  • can be mocked and tested from an OpenAPI contract.

To implement this in production, start with the /verify schema, generate a mock server, build the FastAPI endpoint, and use saved Apidog test scenarios to catch regressions as the verdict logic evolves.

Top comments (0)