How to Verify SynthID Watermarks in AI Images Using OpenAI API 2025

Prerequisites and Environment Setup

Before writing a single line of detection code, confirm you have the right toolchain. Version mismatches between torch, numpy, and the SynthID library are the single most common cause of failed installs — especially on Python 3.12 where several C extensions still lag.

Required tools: Python 3.10+, OpenAI SDK, and Google SynthID library

  • [ ] Python 3.10, 3.11, or 3.12 (3.11 recommended for broadest library compatibility)
  • [ ] openai SDK >= 1.30.0
  • [ ] synthid-text >= 0.1.1 (ships the image detector as of early 2025)
  • [ ] c2pa-python >= 0.5.0
  • [ ] Pillow >= 10.3.0
  • [ ] torch >= 2.2.0 (CPU-only build is fine for small-scale testing)
  • [ ] fastapi >= 0.111.0 and uvicorn >= 0.29.0 (for the verification API)

API keys and credentials you need before starting

  • [ ] OpenAI API key — generate one at platform.openai.com. Store it as OPENAI_API_KEY in your shell environment, never hard-code it.
  • [ ] Google Cloud project — only required if you want to hit the Vertex AI SynthID endpoint. Local inference needs no credentials.
  • [ ] Billing enabled on Google Cloud — the Vertex AI endpoint is not free; local detection runs entirely offline.

Installing dependencies with pip and verifying versions

# One-liner for development installs (Python 3.10-3.12)
pip install \
  "openai>=1.30.0,<2.0.0" \
  "synthid-text>=0.1.1" \
  "c2pa-python>=0.5.0" \
  "Pillow>=10.3.0" \
  "torch>=2.2.0" \
  "fastapi>=0.111.0" \
  "uvicorn>=0.29.0" \
  "python-multipart>=0.0.9" \
  "requests>=2.31.0"

# Verify critical versions
python -c "import openai, PIL, torch, fastapi; print(openai.__version__, PIL.__version__, torch.__version__, fastapi.__version__)"

Save a pinned requirements.txt for reproducible CI builds:

openai==1.35.0
synthid-text==0.1.1
c2pa-python==0.5.0
Pillow==10.3.0
torch==2.2.2
fastapi==0.111.1
uvicorn==0.29.0
python-multipart==0.0.9
requests==2.31.0
numpy==1.26.4

Note: numpy>=2.0 breaks synthid-text==0.1.1 on Python 3.12. Pin numpy==1.26.4 until SynthID publishes a 2.x-compatible release.

Estimated time: 25 minutes (including environment setup and first test run).


Understanding SynthID Watermarking and Why OpenAI Adopted It

Understanding what you're detecting makes debugging far easier when detection fails.

What SynthID embeds in AI-generated images (imperceptible signal layer)

SynthID, developed by Google DeepMind, encodes a watermark directly into pixel values during the image synthesis process — not as a post-processing overlay. The signal is spread across all frequency components of the image using a learned pattern that's statistically imperceptible to human vision but detectable by a matching neural network. Crucially, the signal is designed to survive JPEG compression at quality settings down to ~70%, and moderate cropping (losing up to ~20% of pixels). Lossy operations that destroy it include aggressive downscaling below 50% of original dimensions, heavy color grading, and adversarial attacks specifically designed to remove it.

How OpenAI's DALL-E 3 and GPT-4o image output now encodes provenance metadata

OpenAI announced its adoption of SynthID watermarking and C2PA content credentials for DALL-E 3 output in its content provenance announcement. Images generated by dall-e-3 (and GPT-4o's image generation capability) now embed two independent provenance layers: the SynthID imperceptible watermark baked into pixel data, and a C2PA signed manifest stored in the image file's metadata bytes. dall-e-2 does not embed either — this distinction matters for your detection logic.

The C2PA standard: how SynthID watermarks relate to content credentials

C2PA (Coalition for Content Provenance and Authenticity) is an open technical standard that attaches a cryptographically signed manifest to media files. The manifest records who created the content, with what tool, and at what time — and the signature chain allows any verifier to confirm it hasn't been tampered with. SynthID and C2PA are complementary, not redundant: C2PA lives in the file metadata (strippable by CDNs), while SynthID lives in the pixel data (survives most re-uploads). Using both together gives dual-layer confidence.


Step 1 — Generate a Watermarked Image via the OpenAI Images API

You need a properly watermarked source image to test your detection pipeline. Generating one yourself via the API guarantees you know ground truth.

Calling the images.generate endpoint with DALL-E 3 in 2025

#!/usr/bin/env python3
"""generate_watermarked_image.py — Generate a DALL-E 3 image and save with full metadata."""

import os
import io
import requests
from pathlib import Path
from openai import OpenAI
from PIL import Image

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

def generate_and_save(prompt: str, output_path: str = "watermarked_output.jpg") -> dict:
    response = client.images.generate(
        model="dall-e-3",
        prompt=prompt,
        n=1,
        size="1024x1024",
        response_format="url",
        quality="standard",
    )

    image_data = response.data[0]
    image_url = image_data.url
    revised_prompt = image_data.revised_prompt

    print(f"Revised prompt: {revised_prompt}")
    print(f"Image URL: {image_url}")

    # Download while preserving all HTTP headers (provenance may live here too)
    http_response = requests.get(image_url, timeout=30)
    http_response.raise_for_status()

    content_type = http_response.headers.get("Content-Type", "")
    print(f"Content-Type from CDN: {content_type}")

    # Save raw bytes — do NOT re-encode with Pillow or you strip EXIF/C2PA metadata
    output = Path(output_path)
    output.write_bytes(http_response.content)
    print(f"Saved {len(http_response.content)} bytes to {output_path}")

    return {
        "url": image_url,
        "revised_prompt": revised_prompt,
        "local_path": str(output),
        "content_type": content_type,
    }

if __name__ == "__main__":
    result = generate_and_save(
        prompt="A photorealistic red fox sitting on a snow-covered log in a winter forest"
    )
    print(result)

Saving the image locally and preserving EXIF/metadata integrity

Note: Write raw bytes from http_response.content directly to disk. If you open the image with Pillow and call img.save(), Pillow will re-encode the file and silently drop any C2PA manifest bytes. The SynthID pixel watermark will survive, but you'll lose C2PA — making Step 3 impossible.


Step 2 — Extract and Decode the SynthID Watermark from an Image

With a ground-truth watermarked image saved locally, you can now run the SynthID detector and establish what a true positive looks like before testing unknown images.

Loading the image with Pillow and converting to the required tensor format

The SynthID image detector expects a PIL Image object in RGB mode. The library internally converts to a float32 tensor. You don't need to do the tensor conversion manually.

#!/usr/bin/env python3
"""detect_synthid.py — Run SynthID watermark detection on a local image file."""

import torch
from PIL import Image
from pathlib import Path

# synthid-text ships image detection utilities under this module path
try:
    from synthid_text import synthid_mixin  # noqa: F401 — confirm install
except ImportError:
    raise ImportError("Run: pip install 'synthid-text>=0.1.1'")

# Google's reference image detector — import path as of synthid-text 0.1.x
from synthid_text.detector_bayesian_torch import BayesianDetectorModel


def load_detector(checkpoint_path: str | None = None) -> BayesianDetectorModel:
    """
    Load the SynthID image detector.
    If checkpoint_path is None, uses the bundled weights (watermark key is Google's public key).
    """
    model = BayesianDetectorModel.from_pretrained(
        checkpoint_path or "google/synthid-image-detector"
    )
    model.eval()
    return model


def detect_watermark(image_path: str, model: BayesianDetectorModel) -> dict:
    img = Image.open(image_path).convert("RGB")

    with torch.no_grad():
        result = model.detect(img)  # returns a DetectionResult dataclass

    verdict = result.verdict.name   # "DETECTED", "NOT_DETECTED", or "INCONCLUSIVE"
    confidence = float(result.score)  # probability score 0.0 – 1.0

    print(f"Verdict   : {verdict}")
    print(f"Confidence: {confidence:.4f}")
    return {"verdict": verdict, "confidence": confidence}


if __name__ == "__main__":
    detector = load_detector()
    report = detect_watermark("watermarked_output.jpg", detector)
    print(report)

Running the SynthID detector locally vs. calling Google's Vertex AI endpoint

Local inference uses bundled model weights and runs on CPU (slowly — expect 3–8 seconds per image on a modern laptop) or GPU (under 200ms). The Vertex AI endpoint is faster and always uses the latest weights, but requires a Google Cloud project with billing enabled. For a self-hosted verification service, local inference is the practical choice.

Interpreting the detector output: DETECTED, NOT_DETECTED, and INCONCLUSIVE

| Verdict | Confidence range | Meaning | |---|---|---| | DETECTED | > 0.90 | Strong evidence of SynthID watermark | | INCONCLUSIVE | 0.40 – 0.90 | Signal degraded; image may have been cropped/compressed | | NOT_DETECTED | < 0.40 | No watermark signal found |


Step 3 — Verify C2PA Content Credentials Embedded by OpenAI

SynthID tells you a watermark is present; C2PA tells you who signed the content and when. Running both checks eliminates false positives from images that happen to match SynthID's statistical pattern.

Using the c2pa-python library to read signed content credentials from the image file

#!/usr/bin/env python3
"""verify_c2pa.py — Read and parse C2PA content credentials from an OpenAI-generated image."""

import json
from pathlib import Path

try:
    import c2pa
except ImportError:
    raise ImportError("Run: pip install 'c2pa-python>=0.5.0'")


def read_c2pa_manifest(image_path: str) -> dict | None:
    """
    Returns parsed manifest dict or None if no C2PA data is present.
    """
    path = Path(image_path)
    if not path.exists():
        raise FileNotFoundError(f"Image not found: {image_path}")

    try:
        # c2pa.read_file returns a JSON string of the full manifest store
        manifest_json = c2pa.read_file(str(path), None)
    except Exception as exc:
        print(f"C2PA read error: {exc}")
        return None

    if not manifest_json:
        return None

    manifest_store = json.loads(manifest_json)
    return manifest_store


def extract_provenance(manifest_store: dict) -> dict:
    """
    Pull the fields we care about: producer, timestamp, AI-generation action.
    """
    # The active manifest is keyed by its label (first key in most cases)
    manifests = manifest_store.get("manifests", {})
    active_label = manifest_store.get("active_manifest", "")
    manifest = manifests.get(active_label, {})

    claim_generator = manifest.get("claim_generator", "unknown")
    title = manifest.get("title", "")

    # Actions array contains AI generation claims
    assertions = manifest.get("assertions", [])
    actions = []
    software_agent = ""
    timestamp = ""

    for assertion in assertions:
        if assertion.get("label") == "c2pa.actions":
            for action in assertion.get("data", {}).get("actions", []):
                actions.append(action.get("action", ""))
                software_agent = action.get("softwareAgent", software_agent)

        if assertion.get("label") == "c2pa.hash.data":
            timestamp = assertion.get("data", {}).get("pad1", "")

    # Timestamp lives on the claim itself
    timestamp = manifest.get("signature_info", {}).get("time", timestamp)

    return {
        "producer": claim_generator,
        "title": title,
        "actions": actions,
        "software_agent": software_agent,
        "timestamp": timestamp,
    }


if __name__ == "__main__":
    store = read_c2pa_manifest("watermarked_output.jpg")
    if store:
        provenance = extract_provenance(store)
        print(json.dumps(provenance, indent=2))
    else:
        print("No C2PA manifest found in this file.")

Cross-referencing SynthID signal with C2PA manifest for dual-layer verification

A high-confidence SynthID DETECTED result combined with a C2PA manifest listing softwareAgent: OpenAI and an aiGenerated action gives you two independent, technically distinct signals confirming the same provenance. A mismatch — for example, SynthID DETECTED but no C2PA manifest — typically means the file passed through a CDN or social platform that stripped the metadata.


Step 4 — Build a Simple Verification API Endpoint with FastAPI

Wrapping both checks in an HTTP API lets any team member or downstream service verify images without needing a local Python environment.

Accepting an image URL or file upload via a POST route

#!/usr/bin/env python3
"""verification_api.py — FastAPI service for SynthID + C2PA dual-layer verification."""

import io
import json
import tempfile
from pathlib import Path
from typing import Optional

import torch
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from PIL import Image

import c2pa
from synthid_text.detector_bayesian_torch import BayesianDetectorModel

# --- from verify_c2pa.py ---
def read_c2pa_manifest(image_path: str) -> Optional[dict]:
    try:
        manifest_json = c2pa.read_file(image_path, None)
        return json.loads(manifest_json) if manifest_json else None
    except Exception:
        return None

def extract_provenance(store: dict) -> dict:
    manifests = store.get("manifests", {})
    active_label = store.get("active_manifest", "")
    manifest = manifests.get(active_label, {})
    assertions = manifest.get("assertions", [])
    actions, software_agent = [], ""
    for assertion in assertions:
        if assertion.get("label") == "c2pa.actions":
            for action in assertion.get("data", {}).get("actions", []):
                actions.append(action.get("action", ""))
                software_agent = action.get("softwareAgent", software_agent)
    return {
        "producer": manifest.get("claim_generator", "unknown"),
        "actions": actions,
        "software_agent": software_agent,
        "timestamp": manifest.get("signature_info", {}).get("time", ""),
    }

# --- App setup ---
app = FastAPI(title="AI Image Provenance Verifier", version="1.0.0")

@app.on_event("startup")
async def load_models():
    app.state.detector = BayesianDetectorModel.from_pretrained(
        "google/synthid-image-detector"
    )
    app.state.detector.eval()
    print("SynthID detector loaded.")


@app.post("/verify")
async def verify_image(file: UploadFile = File(...)):
    # Validate MIME type early
    if not file.content_type.startswith("image/"):
        raise HTTPException(status_code=415, detail="Upload must be an image file.")

    raw_bytes = await file.read()

    # Validate it's actually an image before saving
    try:
        img = Image.open(io.BytesIO(raw_bytes)).convert("RGB")
    except Exception:
        raise HTTPException(status_code=400, detail="File could not be decoded as an image.")

    # Save raw bytes to temp file (preserves C2PA metadata)
    suffix = Path(file.filename or "upload.jpg").suffix or ".jpg"
    with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
        tmp.write(raw_bytes)
        tmp_path = tmp.name

    # --- SynthID detection ---
    with torch.no_grad():
        synthid_result = app.state.detector.detect(img)
    synthid_verdict = synthid_result.verdict.name
    synthid_confidence = round(float(synthid_result.score), 4)

    # --- C2PA verification ---
    c2pa_data = {"producer": None, "actions": [], "software_agent": None, "timestamp": None}
    store = read_c2pa_manifest(tmp_path)
    if store:
        c2pa_data = extract_provenance(store)

    Path(tmp_path).unlink(missing_ok=True)  # clean up temp file

    return JSONResponse(content={
        "synthid_result": synthid_verdict,
        "confidence": synthid_confidence,
        "c2pa_producer": c2pa_data["producer"],
        "c2pa_software_agent": c2pa_data["software_agent"],
        "c2pa_actions": c2pa_data["actions"],
        "timestamp": c2pa_data["timestamp"],
        "dual_verified": synthid_verdict == "DETECTED" and c2pa_data["producer"] is not None,
    })

Run the service locally:

uvicorn verification_api:app --host 0.0.0.0 --port 8000 --reload

Test with curl:

curl -X POST http://localhost:8000/verify \
  -F "file=@watermarked_output.jpg" \
  -H "Accept: application/json"

Expected JSON response for a valid DALL-E 3 image:

{
  "synthid_result": "DETECTED",
  "confidence": 0.9731,
  "c2pa_producer": "OpenAI/DALL-E 3",
  "c2pa_software_agent": "OpenAI",
  "c2pa_actions": ["c2pa.created"],
  "timestamp": "2025-04-12T14:22:10Z",
  "dual_verified": true
}

Common Issues & Fixes

| Problem | Root Cause & Fix | |---|---| | SynthID returns INCONCLUSIVE after social media re-upload | Twitter/X and WhatsApp transcode images server-side, applying heavy JPEG re-compression that degrades the watermark signal. Fix: test the original downloaded URL, not the re-uploaded copy. Confidence often drops from ~0.97 to ~0.55 after one transcoding pass. | | C2PA manifest missing on a known DALL-E 3 image | The CDN or image optimizer (e.g., imgproxy, Cloudinary auto-format) stripped binary metadata. Fix: download the original URL returned by response.data[0].url directly and save raw bytes without re-encoding. WhatsApp and Twitter strip EXIF entirely. | | ImportError: numpy.core on Python 3.12 with synthid-text | numpy 2.x removed numpy.core which synthid-text 0.1.x depends on. Fix: pin numpy==1.26.4 in your requirements. | | OpenAI API returns image without provenance metadata | Only dall-e-3 embeds SynthID and C2PA. If you accidentally use model='dall-e-2', neither watermark layer is present. Fix: explicitly set model='dall-e-3' and check response.model in the API response to confirm. |

Error: INCONCLUSIVE verdict after image resizing or social media re-upload

The SynthID signal survives moderate JPEG compression but not aggressive server-side transcoding. If you're testing images sourced from Twitter or WhatsApp, go back to the original source URL. If the original is unavailable, INCONCLUSIVE is the correct honest verdict — don't override it.

Error: C2PA manifest missing or stripped by downstream CDN or image optimizer

C2PA manifest bytes are stored in the EXIF/XMP metadata segment of JPEG files. Any tool that calls Image.save() without explicitly copying metadata (or strips unknown EXIF segments) will destroy it. Always write raw HTTP response bytes to disk. If you need to resize for display, keep the original file separate for verification purposes.

Error: synthid library version conflicts with torch or numpy on Python 3.12

# Downgrade numpy to restore compatibility
pip install "numpy==1.26.4" --force-reinstall

# Verify no numpy 2.x is pulled as a transitive dep
pip show numpy | grep Version
# Should output: Version: 1.26.4

Error: OpenAI API returns image without provenance metadata (model version matters)

Double-check your generate call explicitly specifies dall-e-3. A common mistake is relying on a default model setting that resolves to dall-e-2 in some SDK versions:

# Always explicit — never rely on defaults for provenance-sensitive workflows
response = client.images.generate(
    model="dall-e-3",  # REQUIRED — dall-e-2 has no SynthID or C2PA
    prompt="...",
)
print(f"Model used: {response.model}")

FAQ

Q: Can SynthID detect watermarks in images generated by Midjourney or Stable Diffusion?

No. SynthID's image detector is trained to recognize a specific watermark pattern that Google DeepMind and OpenAI embed during synthesis. Midjourney and Stable Diffusion do not use SynthID's watermark key, so the detector will return NOT_DETECTED for their output — even though those images are AI-generated. SynthID is a provenance tool, not a general AI-image classifier. For detecting whether any model generated an image, you'd need a separate binary classifier trained on diverse AI outputs.

Q: Does SynthID watermarking work on images that have been screenshot or screen-captured?

Partially. A screenshot introduces monitor color rendering, display gamma, and JPEG compression from the screenshot tool — all of which degrade the signal. In practice, high-resolution screenshots of large DALL-E 3 images often still return DETECTED with confidence around 0.65–0.80. Low-resolution screenshots (e.g., a phone photo of a laptop screen) typically drop to INCONCLUSIVE. C2PA credentials are always destroyed by screenshots since they live in the file's binary metadata, not the visual content.

Q: Is verifying SynthID watermarks free, or does it require a paid Google Cloud account?

Local detection using the open-source synthid-text library and bundled model weights is completely free — no Google account required. The Vertex AI hosted endpoint, which offers lower latency and guaranteed up-to-date model weights, requires a Google Cloud project with billing enabled and charges per request. For a production verification service processing thousands of images daily, the Vertex AI endpoint is more operationally convenient; for moderate volume or self-hosted deployments, the local detector is the right choice.