Someone uploads a photo to your product and claims a camera took it. Can your backend prove or disprove that? Image generators now produce results that look real to a human reviewer, so “trust the eyes” stopped working a while ago. The good news is that you do not need to train your own model to ship a useful answer. You can combine two independent signals, a cryptographic provenance manifest and a machine learning classifier, into one verdict that is more honest than either signal alone.
This tutorial walks through building that backend as a single service with a POST /verify endpoint. You give it an image, it returns a JSON verdict with a confidence score and the provenance details it found. We will use Python and FastAPI for the server, the open-source C2PA tooling for the provenance signal, and a hosted detection API for the classifier signal. Because this is an API project, we will also design the endpoint contract first and use Apidog to mock and test it, so your frontend team can start integrating before the backend code is finished.
TL;DR
You will build a FastAPI service exposing POST /verify that accepts an image upload, extracts and validates its C2PA Content Credentials manifest with the c2pa-python library, calls a hosted AI-detection classifier as a second independent signal, and returns a single JSON verdict (likely_authentic, likely_ai, or uncertain) with a confidence score and the raw provenance details. You will also design the OpenAPI schema for the endpoint and use Apidog to generate a mock server and run endpoint tests against it.
Why two signals instead of one
Before any code, it helps to be clear about what you are detecting. There is no single property of a file that tells you “a human made this” or “an AI made this.” Instead there are clues, and each clue catches a different kind of image while missing others.
The first clue is provenance. C2PA, the Coalition for Content Provenance and Authenticity, is an open standard that attaches tamper-evident, cryptographically signed metadata to a media file. That metadata bundle is called a manifest, and the user-facing name for it is Content Credentials. When a participating tool, a camera, an editor, or an image generator, creates or changes an image, it can write a manifest that records what happened and signs it with a certificate. If you can read and validate that manifest, you get a strong, verifiable statement about the image’s history.
The catch: C2PA is opt-in, and the manifest is fragile. A screenshot strips it. Re-encoding through a messaging app strips it. Many platforms remove metadata on upload. So a missing manifest tells you almost nothing; it does not mean the image is fake, and it does not mean it is real.
The second clue is a statistical classifier. A detection model is trained on millions of real and generated images and learns the visual artifacts that generators tend to leave behind. It works on any image, with or without metadata, but it is probabilistic. It returns a likelihood, not a fact, and it can be wrong, especially on images outside its training distribution or images that have been heavily compressed.
Neither signal is enough on its own. Provenance is precise but rarely present. The classifier is always available but never certain. Combine them and you get a verdict that says, in effect, “here is what the cryptography proves, here is what the model estimates, and here is how confident the combination makes us.” That is the design goal. If you want a deeper look at why single-signal approaches fall short, our piece on why AI image detection fails covers the failure modes in detail.
Architecture overview
The service is small on purpose. One endpoint, two downstream calls, 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
Step 1 checks that the upload is a real image of a supported type and within a size limit. Step 2 reads the C2PA manifest locally; no network call, just parsing and certificate validation. Step 3 sends the image bytes to a hosted classifier over HTTPS. Step 4 merges the two results with a small rules function and returns the verdict.
The two signal steps are independent. That matters for error handling: if the classifier times out, you can still return a partial verdict from the provenance signal, and the other way around. We will come back to that in the hardening section.
For the stack, Python 3.10 or newer is required because the C2PA library needs it. You will use FastAPI for the web layer, Uvicorn to run it, python-multipart for file uploads, httpx for the outbound classifier call, and c2pa-python for provenance.
pip install fastapi "uvicorn[standard]" python-multipart httpx c2pa-python
The C2PA signal
The Content Authenticity Initiative, under the contentauth GitHub organization, publishes the open-source C2PA tooling. There are two pieces you will hear about:
c2patool, a command-line tool for displaying and adding manifests. It is handy for quick inspection from a terminal. Note that its standalone repository is now archived, and the CLI lives inside thec2pa-rsRust project.c2pa-python, the Python binding for the same underlying Rust library (c2pa-rs). This is what your service will use. It is published on PyPI asc2pa-pythonand installed withpip install c2pa-python.
The library’s read path centers on a Reader object. You point it at an image, then ask for the manifest store as JSON. Here is the core of the provenance module.
# 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 the expected case for most images.
if str(err).startswith("ManifestNotFound"):
return {
"has_manifest": False,
"validation": "none",
"detail": "No C2PA manifest present in this image.",
}
# Any other C2paError means 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.",
}
A few notes on what the code does. The Reader is used as a context manager so the underlying resources are released. reader.json() returns the full manifest store as a JSON string; the library also offers reader.detailed_json() if you want the long-form report with every assertion and ingredient. The expected outcome for most uploads is a C2paError whose message starts with ManifestNotFound, because most images simply have no Content Credentials. Treat that as data, not a failure.
When a manifest is present, two fields matter most for a verdict. The claim_generator string tells you which tool wrote the manifest, for example a camera firmware string or an AI image tool’s name. The validation_status array is empty when the signature and hashes check out, and populated with error codes when they do not. An invalid manifest is a red flag worth surfacing loudly; it means the file claims a history that the cryptography does not back up.
What this signal cannot do: it cannot give you a verdict when there is no manifest, which is most of the time. That is exactly why you need the second signal.
The classifier signal
The classifier is a hosted API that scores an image’s likelihood of being AI-generated. Several vendors offer this. This tutorial uses Sightengine because its AI-detection model has a documented HTTP API and a clear response shape, but the pattern is the same for any provider; you swap the URL, the parameters, and the field you read. If you are weighing options, our roundup of the best AI image detection APIs compares accuracy, pricing, and coverage across vendors.
Sightengine’s check endpoint is https://api.sightengine.com/1.0/check.json. You POST the image as media, set models to genai, and pass your api_user and api_secret. The response includes type.ai_generated, 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)}
The function is async so a slow classifier does not block the event loop. The timeout is explicit and short; eight seconds is a sensible default for an interactive endpoint, and you should tune it to your provider’s real latency. Every failure path returns available: False with a machine-readable reason instead of raising. That is deliberate: a classifier outage should degrade the verdict, not crash the request. The verdict logic in the next section reads available and decides what to do.
Treat the score as an estimate. A 0.92 is “the model is fairly sure,” not “this is proven AI.” Vendors update their models, and accuracy varies by generator and by how much the image was compressed before it reached you. For a broader view of how these tools behave in practice, see our guide on how to check if an image is AI generated.
Designing the /verify contract
Here is the part where Apidog earns its place. Before writing the route handler, design the request and response as an OpenAPI schema. Doing this first gives you three things: a single source of truth both teams agree on, a mock server the frontend can call immediately, and a test suite you can run the moment the backend exists.
The request
POST /verify takes a multipart/form-data body with one field, image, the file to check. Keep it that simple. Optional query parameters can come later.
The response
The response is where the design work pays off. It must show the final verdict, the confidence, and both raw signals so a caller can audit the decision. Here is the shape.
{
"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"
}
verdict is one of three string values: likely_authentic, likely_ai, or uncertain. Three values, not two, because honesty matters; when the signals disagree or are both weak, “uncertain” is the correct answer. confidence is a 0-to-1 float describing how strongly the signals support that verdict. signals carries both raw inputs so the caller can show their own UI or apply their own policy. explanation is a human-readable sentence for support staff and logs.
Expressing this as an OpenAPI schema is straightforward. Here is the response component you would put in your spec.
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 }
You can author this schema directly in Apidog’s visual designer or import an existing OpenAPI file. Designing the API before the implementation is a workflow worth adopting in general; our spec-first mode walkthrough shows how to do it end to end in Apidog.
The code walkthrough
Now the pieces come together. Below is the FastAPI app: input validation, both signal calls, the combining function, and the route.
Combining the two signals
The verdict function is the heart of the service. It encodes your policy. Provenance, when valid and present, is the stronger signal because it is cryptographic; the classifier is a tiebreaker and a fallback. Here is a clear, conservative version.
# 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: a valid manifest naming an AI generator. Strong AI signal.
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: a valid manifest from a camera or non-AI editor. Strong authentic signal.
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: a manifest that fails validation. Treat as suspicious.
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. Fall back entirely to 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. We genuinely cannot say.
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}
Read through the five cases and you can see the policy. A valid manifest dominates. A failed manifest is a warning, not proof of fakery, so it lands on “uncertain.” A conflict between a clean manifest and a high classifier score also lands on “uncertain” rather than picking a side. And when both signals are missing, the service says so honestly with zero confidence rather than guessing. You will tune these thresholds for your own risk tolerance; a content platform and a newsroom would draw the lines differently.
The FastAPI app
# 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 the upload.
if image.content_type not in ALLOWED_TYPES:
raise HTTPException(
status_code=415,
detail=f"Unsupported type {image.content_type}. "
f"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. Provenance signal. The C2PA reader needs a file path,
# so write to a temp file and clean up afterward.
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. Classifier signal. Failures return available: False, not exceptions.
if SIGHTENGINE_USER and SIGHTENGINE_SECRET:
classifier = await classify_image(
image_bytes, image.filename or "upload",
SIGHTENGINE_USER, SIGHTENGINE_SECRET,
)
else:
classifier = {"available": False, "reason": "classifier_not_configured"}
# 4. Combine and respond.
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(),
})
Run it locally with uvicorn main:app --reload and the endpoint is live on http://127.0.0.1:8000/verify. The C2PA reader expects a file path, so the handler writes the upload to a temp file and deletes it in a finally block; the classifier works straight from bytes. Notice the request never crashes on a missing manifest or an absent classifier; both are normal states the verdict function handles.
A backend designed this way, a focused service with a clean contract, fits the broader trend of products exposing their core capability through an API. If that idea interests you, our essay on software going headless is worth a read.
Testing and mocking with Apidog
Here is the workflow problem: your frontend team wants to build the upload UI and the results panel now, but the backend above takes a few days to finish, get keys for, and deploy. You do not want them blocked. This is what mock servers are for, and it is where designing the schema first pays off.
Generate a mock server from the schema
Import the OpenAPI schema into Apidog, or build the /verify endpoint in the visual designer. Apidog reads the response schema and generates a mock server automatically. Because the schema defines field types and enums, the mock returns data shaped exactly like the real endpoint: a verdict that is one of the three enum values, a confidence float between 0 and 1, a populated signals object. The frontend points its fetch call at the Apidog mock URL and gets realistic responses on day one. When the real backend ships, they change one base URL.
The mock is also where you exercise the hard cases before any real code exists. Define example responses for the verdicts that matter:
- a
likely_authenticresponse with a valid camera manifest, - a
likely_airesponse with an AI tool named in the manifest, - an
uncertainresponse where the classifier was unavailable, - the
415and413error responses.
The frontend can build and style every state, including the error states, against the mock. That is how you ship a UI and an API in parallel instead of in sequence.
Run endpoint tests in Apidog
Once the backend is running, create a request in Apidog for POST /verify. Set the method, point it at your local URL, and in the Body tab choose form-data, add the image field, set its type to File, and pick a test image from disk.
Send it, and Apidog shows the JSON response. Now add assertions so this becomes a repeatable check rather than a one-off click:
- assert the response status is
200, - assert
verdictexists and is one of the three allowed strings, - assert
confidenceis a number between 0 and 1, - assert
signals.provenance.has_manifestis a boolean.
Build a small test scenario that runs several uploads in sequence: an image with Content Credentials, a plain JPEG with no manifest, an oversized file, and a non-image file renamed with a .jpg extension. Each one checks a different branch of your verdict logic and your input validation. Save the scenario and you can re-run the whole suite after every change, or wire it into CI so a regression in the verdict function fails the build. Testing an upload endpoint by hand with curl gets old fast; a saved scenario does not.
Hardening and edge cases
The happy path is the easy 80 percent. A verification service lives or dies on the other 20 percent, because the inputs are adversarial by nature; someone is trying to pass off an image as something it is not.
Corrupt or truncated files. A file can have an image MIME type and still be garbage. The C2PA reader raises a C2paError on data it cannot parse, and the read_provenance function already turns that into a clean result instead of a 500. For belt and suspenders, you can decode the image with a library like Pillow before processing and reject it with a 400 if the decode fails. That also blocks the renamed-text-file trick.
Missing manifest. Covered, but worth restating because it is the most common case and the easiest to get wrong. No manifest is not an error and not a verdict. The library signals it with a C2paError whose message starts with ManifestNotFound; the service catches that specifically and moves on to the classifier. Never let a missing manifest produce a 500 or a “fake” verdict.
Classifier timeout or outage. The classifier is a third-party network dependency, so assume it will fail sometimes. The classify_image function uses an explicit httpx timeout and returns available: False on any timeout or HTTP error. The verdict function then falls back to provenance alone, or returns uncertain with zero confidence if there is also no manifest. The endpoint still responds with a 200 and an honest verdict; a slow vendor cannot take your service down.
Spoofed manifests. A manifest can be present but invalid, signed with a bad certificate or with hashes that do not match the pixels. This is the case people forget. Always check validation_status; an empty array means the manifest verified, a populated one means it did not. The verdict function treats a failed manifest as a warning that lands on uncertain, never as proof. Trusting an unvalidated manifest is worse than having no manifest at all.
Large files and abuse. Cap the upload size, the example uses 12 MB, and reject anything larger with a 413 before reading the whole body into memory where you can. Put a rate limit in front of the endpoint; cryptographic validation and an outbound API call per request are not free, and an open verification endpoint is an inviting target.
Privacy. You are receiving user images. Process them in memory or in a temp file you delete immediately, as the example does, and do not log image bytes. If you are sending images to a third-party classifier, say so in your privacy policy and make sure that is allowed for your use case.
What each signal catches and misses
This table is the mental model to keep. It is why the service uses both.
| 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 (screenshot, re-upload) | Misses it: no manifest to read | Catches it: works on pixels, no metadata needed |
| 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 at all | No signal: nothing to validate | Best guess only: probabilistic, can be wrong |
| Image with a forged or tampered manifest | Catches it: validation_status flags the failure |
May or may not catch it, depends on the pixels |
| Novel generator the classifier was not trained on | Catches it only if the tool writes a manifest | Often misses it: outside training distribution |
| Heavily edited real photo (AI retouch on a real base) | Manifest, if present, records the edit history | Ambiguous: partially synthetic, score lands mid-range |
Read across any row and you see the same story: where one signal is blind, the other often is not. Provenance is exact but sparse; the classifier is universal but fuzzy. The combined verdict is more trustworthy than either column alone, and the honest uncertain value exists for the rows where both signals are weak.
Real-world use cases
This pattern is not academic. A few places it fits directly:
- User-generated content platforms. A marketplace or social app can run uploads through
/verifyand label or queue for review anything that scores as likely AI, or anything carrying a manifest that fails validation. The three-value verdict maps cleanly to “allow,” “flag,” and “send to a human.” - Newsrooms and fact-checking. An editor checking a viral image gets both the cryptographic provenance, if any, and an independent model estimate in one call, with an explanation sentence they can quote in their notes.
- Insurance and claims intake. When a customer submits photo evidence, a verification step raises a flag on images that look generated or carry a tampered manifest, before a human adjuster spends time on them.
- Internal asset pipelines. A team that needs to keep AI-generated images out of, or clearly labeled in, a stock library can gate ingestion on
/verify. - Provenance-aware publishing. As more cameras and editors adopt Content Credentials, a CMS can read the manifest on upload and display a verified badge, falling back to the classifier when no manifest is present.
The common thread: you want a fast, automated first pass that is honest about its own uncertainty, so human attention goes where it is actually needed.
Conclusion
Detecting AI-generated images well is not about finding one perfect test. It is about combining independent signals and being honest about confidence.
- C2PA Content Credentials give you a strong, cryptographically verifiable provenance signal, but the manifest is opt-in and easily stripped, so it is often absent.
- A hosted classifier gives you a universal but probabilistic signal that works on any image, with or without metadata.
- Combining them in a small FastAPI service produces a three-value verdict,
likely_authentic,likely_ai, oruncertain, with a confidence score and both raw signals attached for auditing. - Designing the OpenAPI contract first lets you mock the endpoint in Apidog so the frontend builds in parallel, then run saved test scenarios against the real backend.
- No detector is perfect. Two signals raise confidence; they do not eliminate uncertainty, which is why the
uncertainverdict is a feature, not a gap.
To build this for real, design the /verify schema, generate a mock server, and run your endpoint tests in one place. Download Apidog to design, mock, and test the API as you build it, then move from mock to live backend with a single base-URL change.



