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 oftrainedAlgorithmicMedia, 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
CreativeWorkassertion. - 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.
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.
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:
{
"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:
- 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.
- C2PA manifest. The watermarked image was saved as PNG and signed with a manifest recording the AI origin, the generator and your organization.
- 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.
curl -o hero-banner.signed.png "<download_url from the response>"The same in 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+)
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:
curl -X POST https://api.opencorpo.com/v1/compliance/verify \
-H "X-API-Key: $OPENCORPO_API_KEY" \
-F "file=@hero-banner.signed.png"{
"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
verifycompares 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.