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.
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:
- Accepts an uploaded image.
- Extracts and validates its C2PA Content Credentials manifest with
c2pa-python. - Calls a hosted AI-image detection classifier.
- Combines both signals into one JSON verdict:
likely_authenticlikely_aiuncertain
- Returns a confidence score and the raw signal details.
- 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
The request flow:
- Validate the upload type and size.
- Read the C2PA manifest locally.
- Send the image bytes to a hosted classifier over HTTPS.
- 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
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 thec2pa-rsRust 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.",
}
Key implementation details:
- Use
Readeras 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
ManifestNotFoundas 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
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
}
}
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)}
This function is async so a slow classifier call does not block the event loop.
Failure paths return:
{
"available": false,
"reason": "classifier_timeout"
}
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"
}
Use three verdict values:
likely_authenticlikely_aiuncertain
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
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,
}
This policy is intentionally conservative:
- A valid AI-generator manifest returns
likely_ai. - A valid non-AI manifest returns
likely_authenticunless the classifier strongly disagrees. - A failed manifest returns
uncertain. - No manifest falls back to the classifier.
- No signal returns
uncertainwith0.0confidence.
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(),
}
)
Run the API locally:
uvicorn main:app --reload
The endpoint is available at:
http://127.0.0.1:8000/verify
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:
- Create or import the OpenAPI schema.
- Define
POST /verify. - Add the
multipart/form-datarequest body with theimagefile field. - Add the
VerifyResponseschema. - 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"
}
Create example responses for important states:
-
likely_authenticwith a valid camera manifest, -
likely_aiwith an AI tool named in the manifest, -
uncertainwhen the classifier is unavailable, -
413for oversized uploads, -
415for 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:
- Create a request in Apidog for
POST /verify. - Set the URL to your local backend.
- In the Body tab, choose
form-data. - Add an
imagefield. - Set its type to File.
- Upload a test image.
- Send the request.
Add assertions:
- response status is
200, -
verdictexists, -
verdictis one oflikely_authentic,likely_ai, oruncertain, -
confidenceis a number between0and1, -
signals.provenance.has_manifestis 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"
}
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, oruncertain, - returns confidence and raw signals,
- exposes a clean
POST /verifyAPI, - 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)