New: try each tool in your browser, no account needed. Then one API key with 3 free tries of every API. Get your key →
Compliance6 min read

How to add C2PA Content Credentials to AI-generated images (curl, Python, Node.js)

Sign AI-generated images with a C2PA manifest, an invisible watermark and a perceptual fingerprint in one API call, then verify them. Working curl, Python and Node.js code, plus where it stops protecting you.

OpenCorpo Team

If your product generates images for people in the EU, Article 50(2) of the EU AI Act expects them to be marked as AI-generated in a machine-readable way. C2PA Content Credentials are the most widely used standard for that: a signed record, embedded in the file, saying what made the image, for whom and when.

This guide shows how to add Content Credentials to an AI-generated image with one API call, what ends up in the file, how to check the result, and where the protection stops. Every example runs against the real OpenCorpo API.

What a C2PA manifest records

A C2PA manifest is a small signed data structure stored inside the image file. For an AI-generated image, the useful parts are:

  • An action saying the image was created (c2pa.created) with a digital source type of trainedAlgorithmicMedia, the IPTC term for content made by a trained AI model.
  • The software agent: the generator name and version you pass, for example dall-e-3.
  • The author: your organization, as a schema.org CreativeWork assertion.
  • A cryptographic signature over the claim and a hash of the image data. Change one byte of the image and validation fails.

The final Code of Practice on Transparency of AI-generated Content, published in June 2026, describes signed metadata like this as one layer and an imperceptible watermark as a second. Metadata alone is easy to strip, which is why a watermark sits under it.

Do it yourself, or call an API?

The C2PA specification and its reference SDKs (c2pa-rs, with Python and Node.js bindings) are open source. If you only need the metadata layer and already run that stack, building it yourself is a reasonable choice. Here is what each route involves:

Build it yourself One API call
Signing certificate Obtain one, store and rotate the private key Signed with OpenCorpo's certificate
Embedding Per format: JUMBF in JPEG APP11, caBX chunk in PNG, C2PA chunk in WebP Handled; output is PNG
Invisible watermark Separate implementation and key management Included
Fingerprint registry Separate database and matching logic Included, per account
Verification Build or host a validator POST /v1/compliance/verify

Step 1: Create an API key

Create an account at app.opencorpo.com and create a key under API keys. Keys start with sk_live_ and are shown once. Your first 3 Compliance API calls are free; after that each call is charged to a prepaid balance.

bash
export OPENCORPO_API_KEY=sk_live_...

Keep the key on your server. Never ship it in a browser or mobile app.

Step 2: Sign the image

Send the image as multipart/form-data. PNG, JPEG and WebP up to 20 MB are accepted.

bash
curl -X POST https://api.opencorpo.com/v1/compliance/sign \
  -H "X-API-Key: $OPENCORPO_API_KEY" \
  -F "file=@hero-banner.png" \
  -F "generator=dall-e-3" \
  -F "organization=Acme Corp"
Field Required What it does
file or upload_id yes The AI-generated image, or the id of a file sent through uploads
generator recommended The model that made the image; recorded as the software agent
generator_version no Model version
organization recommended Your organization, recorded as the author

The response describes the signed asset:

json
{
  "id": "ca_9fbaede80a7bc388b35c",
  "object": "compliance_asset",
  "status": "signed",
  "filename": "hero-banner.png",
  "generator": "dall-e-3",
  "organization": "Acme Corp",
  "content_type": "image/png",
  "layers_applied": ["c2pa_metadata", "invisible_watermark", "content_fingerprint"],
  "compliance": {
    "eu_ai_act_article_50": true,
    "digital_source_type": "trainedAlgorithmicMedia",
    "checklist": {
      "machine_readable_marking": true,
      "c2pa_provenance_manifest": true,
      "invisible_watermark": true,
      "fingerprint_registered": true
    }
  },
  "processing_ms": 157,
  "download_url": "https://...",
  "download_url_expires_in": 3600,
  "billing": { "price_key": "compliance.sign", "charged_eur": "0.010000", "free_trial": false }
}

Three things happened in that call:

  1. Watermark. The asset id was written into the least significant bits of the blue channel at pseudo-random positions. It is invisible and carries the id.
  2. C2PA manifest. The watermarked image was saved as PNG and signed with a manifest recording the AI origin, the generator and your organization.
  3. Fingerprint. A 64-bit perceptual hash of the image was stored in your account's registry, so resized or recompressed copies can still be matched later.

Step 3: Download the signed PNG

The download_url is valid for an hour. Signed images are kept until you ask for them to be deleted, and GET /v1/compliance/assets/{id} always returns a fresh link.

bash
curl -o hero-banner.signed.png "<download_url from the response>"

The same in Python

python
import os
import requests

API = "https://api.opencorpo.com"
HEADERS = {"X-API-Key": os.environ["OPENCORPO_API_KEY"]}


def sign_image(path, generator, organization=None, generator_version=None):
    """Sign an AI-generated image; returns (asset_id, path_of_signed_png)."""
    data = {"generator": generator}
    if organization:
        data["organization"] = organization
    if generator_version:
        data["generator_version"] = generator_version

    with open(path, "rb") as fh:
        r = requests.post(
            f"{API}/v1/compliance/sign",
            headers=HEADERS,
            files={"file": (os.path.basename(path), fh)},
            data=data,
            timeout=60,
        )
    if not r.ok:
        err = r.json()["error"]
        raise RuntimeError(f"{err['code']}: {err['message']}")
    asset = r.json()

    signed = requests.get(asset["download_url"], timeout=60)
    signed.raise_for_status()
    out = os.path.splitext(path)[0] + ".signed.png"
    with open(out, "wb") as fh:
        fh.write(signed.content)
    return asset["id"], out


asset_id, signed_path = sign_image("hero-banner.png", "dall-e-3", organization="Acme Corp")
print(asset_id, signed_path)

The same in Node.js (20+)

javascript
import { openAsBlob } from "node:fs";
import { writeFile } from "node:fs/promises";
import path from "node:path";

const API = "https://api.opencorpo.com";
const headers = { "X-API-Key": process.env.OPENCORPO_API_KEY };

export async function signImage(file, { generator, organization } = {}) {
  const form = new FormData();
  form.append("file", await openAsBlob(file), path.basename(file));
  if (generator) form.append("generator", generator);
  if (organization) form.append("organization", organization);

  const res = await fetch(`${API}/v1/compliance/sign`, { method: "POST", headers, body: form });
  const asset = await res.json();
  if (!res.ok) throw new Error(`${asset.error.code}: ${asset.error.message}`);

  const signed = await fetch(asset.download_url);
  const out = file.replace(/\.\w+$/, "") + ".signed.png";
  await writeFile(out, Buffer.from(await signed.arrayBuffer()));
  return { id: asset.id, out };
}

console.log(await signImage("hero-banner.png", { generator: "dall-e-3", organization: "Acme Corp" }));

Errors always use the same envelope, {"error": {"code", "message", "retryable"}}. When your balance cannot cover a call you get 402 insufficient_funds and nothing is charged. A request that fails on our side is never charged either, so it is safe to retry when retryable is true.

Step 4: Verify the result

Send any image to the verify endpoint to check for all three layers:

bash
curl -X POST https://api.opencorpo.com/v1/compliance/verify \
  -H "X-API-Key: $OPENCORPO_API_KEY" \
  -F "file=@hero-banner.signed.png"
json
{
  "has_c2pa": true,
  "c2pa": {
    "claim_generator": "OpenCorpo 1.0",
    "signer": { "issuer": "OpenCorpo", "common_name": "OpenCorpo Platform Signer" },
    "assertions": ["c2pa.actions.v2", "stds.schema-org.CreativeWork"],
    "validation_status": [{ "code": "signingCredential.untrusted", "explanation": "signing certificate untrusted" }]
  },
  "has_watermark": true,
  "watermark_id": "ca_9fbaede80a7bc388b35c",
  "fingerprint_match": true,
  "matched_asset_id": "ca_9fbaede80a7bc388b35c",
  "fingerprint_distance": 0,
  "compliance_status": "compliant",
  "billing": { "price_key": "compliance.verify", "charged_eur": "0.002000", "free_trial": false }
}

compliance_status is compliant when all three layers are found, partial when some are and non-compliant when none are. Nothing you send to verify is stored.

You can also open the signed file in any C2PA validator, such as the Content Credentials Verify tool. It will show the manifest and report the signer as untrusted. That is expected for now: OpenCorpo is a C2PA Conformance Program member, but its signing certificate is not yet on the C2PA trust list. Validators still read the manifest and check its integrity.

Step 5: Publish without re-encoding

Where you sign matters as much as whether you sign. The manifest is bound to the file bytes and the watermark lives in individual pixel values, so anything that re-encodes the image after signing removes both.

  • Sign right after generation, before the image enters your storage or CDN.
  • Exclude signed images from automatic optimization. CDN features that resize, convert to WebP or AVIF, or recompress will strip the manifest and destroy the watermark.
  • Store the asset id next to your own record of the generation (prompt, user, timestamp). It is what the watermark carries and what verify returns.
  • If you also clean metadata, clean first and sign second. The Metadata API removes C2PA manifests along with everything else, so cleaning a signed file would remove the credentials you just added.

Honest limits

  • Images only. PNG, JPEG and WebP up to 20 MB. The API does not mark video, audio or text.
  • Output is PNG. Photographic images will usually be larger than the JPEG you sent.
  • The watermark is fragile to re-encoding. It survives metadata stripping and lossless copies, but not JPEG compression, resizing or screenshots that rescale the image.
  • The fingerprint matches only your own assets. It survives resizing and recompression, but verify compares an image only against the registry of the account making the call.
  • Signer shown as untrusted in third-party validators until OpenCorpo's certificate is on the C2PA trust list.
  • Existing manifests are replaced, not chained. If your generator already embedded Content Credentials, the signed file carries OpenCorpo's manifest instead.
  • Not a legal opinion. Whether this setup meets your obligations depends on your role and products. The Code of Practice is voluntary, and its adequacy assessment was still pending at the time of writing.

Frequently asked questions

Does the EU AI Act require C2PA?

The AI Act asks providers to mark synthetic content in a machine-readable, detectable way, and does not name a standard. The final Code of Practice on Transparency of AI-generated Content (June 2026) describes digitally signed metadata as one layer and an imperceptible watermark as a second. C2PA is the most widely used format for the signed-metadata layer. This is not legal advice.

Does the invisible watermark change how my image looks?

No visible change. It sets the least significant bit of the blue channel in a subset of pixels, so a pixel value moves by at most 1 out of 255.

Why is the signed file always a PNG?

JPEG compression rewrites pixel values and would destroy the watermark, so the signed output is lossless PNG. If you convert it later, the C2PA manifest and the watermark are lost, but the fingerprint still matches the converted copy in your account.

What does signingCredential.untrusted mean in a validator?

The validator read the manifest and checked its integrity, but does not yet recognise the certificate that signed it, because OpenCorpo's signing certificate is not yet on the C2PA trust list. The manifest content is still readable and validated.

Do Content Credentials survive uploading to social media?

Often not: many platforms strip metadata or re-encode images on upload. The watermark survives metadata stripping but not re-encoding or resizing; the fingerprint matches resized and recompressed copies against your own registry.

My image generator already adds C2PA. Do I still need this?

A generator's manifest is signed by the model provider and names its tool. Signing with OpenCorpo writes a new manifest that names your organization and adds the watermark and fingerprint; it replaces the generator's manifest rather than chaining to it.

How much does it cost?

Signing costs €0.01 / image and verifying €0.002 / image, charged to a prepaid balance. Your first 3 Compliance API calls are free. See pricing.


This article is for informational purposes only and does not constitute legal advice. Consult qualified legal counsel for advice specific to your situation.