Build with 0byte
Content Credentials for AI content, as one API call. Stamp embeds a signed C2PA manifest and anchors a derived fingerprint in a public transparency log; verify reads the evidence back — free, for anyone, even from a screenshot.
Overview
0byte is a managed C2PA signing service: origin proofs for AI content that survive screenshots, re-encoding, and stripped metadata. Two verbs cover the flow:
- Stamp — at generation, embed a signed C2PA manifest (Content Credentials) in the file and anchor a derived perceptual fingerprint in a public, append-only transparency log. Your brand goes in the manifest.
- Verify — check any content, free and without a key. It reads standard Content Credentials from any signer — not just 0byte's — and matches fingerprints against the registry. The verdict is built from that evidence, never from a model's guess.
Quickstart
Install an SDK (or use plain HTTP — the API is the contract):
pip install zerobyteStamp a generation, publish the stamped file, and verify anything later:
from zerobyte import Client
client = Client(api_key="0b_key_...")
# 1 · Stamp — Content Credentials + a signed registry proof, with your brand on it
result = client.stamp(
content=open("art.png", "rb").read(),
content_type="image/png",
provider="acme-ai",
model="imagen-x",
creator={"name": "Acme Studios", "url": "https://acme.example"},
)
print(result.binding) # "manifest+registry" — how the proof is bound, always disclosed
with open("art.stamped.png", "wb") as f:
f.write(result.stamped_bytes) # publish THIS file — it carries the manifest
# 2 · Verify — free, evidence-first, no API key
check = Client().verify(open("downloaded.png", "rb").read())
print(check.verdict) # "verified_origin"
print(check.origin_sources) # ["manifest", "registry"]Publish the stamped file, not the original
The response'sstamped_content is your file with the Content Credentials embedded. Publish that one — the original bytes carry no manifest (the registry fingerprint still covers them).Need an API key?
Stamping requires a key. Grab one from your dashboard — join the waitlist for access. Verify, proof lookups, and transparency are public.Authentication
Get a key from your terminal — pip install zerobyte && zerobyte login opens a browser approval and hands the key to your CLI. It is the only way keys are minted; the dashboard and /v1/keys list and revoke. Stamp and key-management requests then authenticate with a bearer token:
Authorization: Bearer 0b_key_...Keys carry scopes, and each authenticated endpoint requires one: stamp for stamping, analyze for the deprecated estimate endpoint, and keys for key management. A key without the required scope gets 403 SCOPE_DENIED.
Public by design
Verify, proof lookup, and the transparency endpoints need no key — anyone can independently check a record, which is the whole point of a public registry. CORS is open, so you can call them straight from a browser.Rate limits
Per API key, per minute:
| Tier | Limit |
|---|---|
| Free | 60 req/min |
| Dev | 300 req/min |
| Pro | 1,000 req/min |
Every response to a key on a rate-limited tier carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (epoch seconds at which one unit of capacity returns — the window slides over the last minute). Over the limit returns 429 RATE_LIMITED with Retry-After in seconds.
Keyless endpoints are limited per IP
Verify, proof lookup, and transparency accept bursts of up to 60 requests per IP, refilling at 1 request per second. Requests carrying an API key use a separate per-IP lane — a 1,000-request burst refilling at about 17 per second — so the tier limit, not the address, is what binds a key. Over either returns429 IP_RATE_LIMITED with Retry-After. High-volume verification belongs behind an API key.How it works
Every stamp creates two bindings at once. The C2PA manifest travels inside the file — standard Content Credentials any C2PA validator can read. The perceptual fingerprint is derived from the pixels — never embedded — and anchored in the public transparency log, so origin survives even when metadata is stripped.
Stamp
Embed a signed manifest with your brand as the claim generator, and anchor the derived fingerprint in the signed, append-only log.
Verify
Read the manifest if present, match the fingerprint against the registry, and return a verdict built only from that evidence.
Why two bindings?
Manifests are strong but strippable — most platforms remove metadata on upload. Fingerprints survive stripping but need a registry. Together, one covers the other's failure mode.Content Credentials
The embedded manifest is standard C2PA. It names your brand as the claim generator (“generated by Acme Studios using imagen-x”), declares AI origin via the IPTC trainedAlgorithmicMedia source type, and carries an org.0byte.proof assertion that links back to the registry record.
Manifest embedding by format:
| Formats | Binding |
|---|---|
| JPEG · PNG · WebP · GIF · TIFF | manifest+registry |
| BMP · ICO | registry_only — no C2PA embed support; the fingerprint still covers them |
| Audio · Video | Not accepted at launch — 415 with the supported list; images only, no overclaiming |
Two assertions, one field
creator is written into the manifest twice: as the C2PA claim_generator, which is what a validator shows as the producing tool, and as a stds.schema-org.CreativeWork assertion whose author is an Organization carrying your name and, if you send one, your url. The first names you in C2PA's own vocabulary; the second says it in terms the wider web already reads.Asserted, not verified — and we say so
Thecreator field is an asserted identity: a claim by whoever holds the API key. Verify responses label it as such. Cryptographically verified identity (CAWG) is a separate, later tier.Trust-list status, honestly
Until 0byte's signing certificate is accepted onto the C2PA trust list, other validators show 0byte-signed manifests as an untrusted signer. Verify discloses the same — and the registry match independently carries theverified_origin verdict for stamped content in the meantime.Fingerprinting
0byte matches content with perceptual hashing rather than exact byte matching. Two visually-identical images produce near-identical fingerprints, so a match holds even after JPEG re-encoding, resizing, or a screenshot — exactly the cases where embedded metadata is destroyed.
The fingerprint (version v2) is a 64-bit perceptual hash: luma → 32×32 → 2-D DCT → the top-left 8×8 low-frequency block → each coefficient compared to the median. Every proof records its fingerprint_version; only the current version takes part in matching.
Blank images have no fingerprint to bind
An image with essentially no luminance variation — a white frame, a flat colour, near-flat noise — hashes to numerical noise shared by every such image. Stamping one returns422 CONTENT_TOO_UNIFORM, and verifying one reports the registry signal as skipped rather than risking a match by accident. Anything with real content — a logo on a white background included — fingerprints normally.distance (0 = exact); the returned match_confidence reflects how close the match is. It is a match-quality number — not an AI-likelihood estimate.matched_as (original, aspect4x5, bottom10, …), so a hit through a crop is disclosed as one. Deeper trims, off-centre windows and cut-out details do not match yet.Verdicts
Verify returns one of four verdicts, each traceable to evidence in the same response. There is no “92% AI” anywhere in a verdict — a probability is a guess, and guesses don't survive appeals.
| Verdict | Meaning |
|---|---|
| verified_origin | A registry match, or a valid manifest from a trusted signer. origin_sources says which. |
| provenance_untrusted | A valid manifest whose signer does not chain to the trust list — a self-claim, disclosed as such, never promoted. |
| provenance_invalid | A manifest is present but fails validation — tampered or corrupted. |
| no_provenance_found | No manifest and no registry match. Absence of evidence — not a fake detector. |
Honest failure beats a silent one
If a signal can't be evaluated, its status sayserror in the evidence. If verification itself can't run, the API returns 503 VERIFICATION_UNAVAILABLE instead of a degraded verdict dressed up as an answer.Want an AI-likelihood estimate anyway? That's the opt-in analyze endpoint — always separate, never part of a verify verdict.
Transparency log
Every proof is appended to a single append-only Merkle tree (RFC 6962-style) and the signed root is republished as the tree grows — new proofs are anchored within about a minute. Anyone can fetch an inclusion proof to confirm a record was committed and never altered, and a consistency proof to confirm history was never rewritten. No blockchain — Ed25519 signatures over an append-only tree.
Python SDK
A thin client over the REST API — httpx + pydantic, no ML on your side.
pip install zerobyteStamp
result = client.stamp(
content=image_bytes,
content_type="image/png",
provider="acme-ai",
model="imagen-x",
creator={"name": "Acme Studios"}, # optional brand attribution
)
result.binding # "manifest+registry" | "registry_only" — always disclosed
result.stamped_bytes # the manifest-embedded file to publish (None if registry_only)
result.verify_url # public proof pageVerify
check = client.verify(open("photo.png", "rb").read())
check.verdict # "verified_origin" | "provenance_untrusted"
# | "provenance_invalid" | "no_provenance_found"
check.origin_sources # which signals prove it, e.g. ["manifest", "registry"]
check.evidence # per-signal detail — statuses always disclosedTransparency
head = client.get_tree_head() # signed root (Ed25519)
inc = client.get_inclusion_proof(pid) # Merkle audit path
keys = client.get_signing_keys() # every key that ever signedErrors are typed and carry the API's code: catch RateLimitError (429) and the retryable VerificationUnavailableError (503); ZerobyteError is the base.
TypeScript SDK
Zero-dependency client for Node and edge runtimes, fully typed.
npm install zerobyteimport { Zerobyte } from "zerobyte";
const zb = new Zerobyte({ apiKey: "0b_key_..." });
const result = await zb.stamp({ content, contentType: "image/png",
provider: "acme-ai", model: "imagen-x" }); // → StampResult
const check = await new Zerobyte().verify(bytes); // → VerificationResult, no key
await zb.getTreeHead(); // signed root
await zb.getInclusionProof(proofId); // Merkle audit path
await zb.getConsistencyProof(64, 128);
await zb.getSigningKeys();Same typed errors as Python: RateLimitError, retryable VerificationUnavailableError, and the ZerobyteError base carrying code and status.
API reference
All endpoints live under https://api.0byte.tech and return JSON. The machine-readable contract is served at GET /v1/openapi.yaml. Within /v1 changes are additive only — existing fields never change meaning or disappear — and every addition is recorded in the changelog.
Stamp
/v1/stampAPI keyEmbed Content Credentials and create a signed registry proof in one call. The response always discloses how the proof is bound.
| Field | Type | Description |
|---|---|---|
contentreq | string | Base64-encoded image (max 10 MB). |
content_typereq | string | MIME type. image/png, image/jpeg, image/webp, image/gif, image/tiff embed a manifest; image/bmp and image/x-icon fall back to registry-only. Anything else — or a declared type that doesn't match the bytes — is refused with 415 and the supported list. |
providerreq | string | AI provider, e.g. acme-ai. |
modelreq | string | Model identifier, e.g. imagen-x. |
creator | object | Brand attribution: {"name", "url?"} — asserted twice in the manifest, as the C2PA claim generator and as the schema.org author. url travels with the author, so the credential carries a link back to you. |
metadata | object | Public. Stored on the proof record, which anyone can fetch — never put prompts, PII, or secrets here. |
curl -X POST https://api.0byte.tech/v1/stamp \
-H "Authorization: Bearer 0b_key_..." \
-H "Content-Type: application/json" \
-d '{
"content": "<base64_encoded_media>",
"content_type": "image/png",
"provider": "acme-ai",
"model": "imagen-x",
"creator": {"name": "Acme Studios", "url": "https://acme.example"},
"metadata": {"prompt_hash": "abc123"}
}'Retries are safe: send an Idempotency-Key
The log is append-only, so a duplicated retry would be a permanent duplicate proof. Send a uniqueIdempotency-Key header per asset (a UUID is fine): a retry with the same key and the same request returns the original proof with replayed: true; the same key with a different request is refused with 409. Keys are scoped to your API key and expire after 24 hours. Without the header every call mints a new proof — a retried timeout included.Verify
/v1/verifyPublicThe evidence-first meta-verifier: reads the embedded manifest, matches the fingerprint against the registry, and returns a verdict built only from that evidence. Free, no key.
| Field | Type | Description |
|---|---|---|
contentreq | string | Base64-encoded image (max 10 MB). |
content_typereq | string | MIME type of the content. |
curl -X POST https://api.0byte.tech/v1/verify \
-H "Content-Type: application/json" \
-d '{
"content": "<base64_encoded_image>",
"content_type": "image/png"
}'verdict— see Verdicts.origin_sources— which signals support the verdict:manifest,registry.evidence— per-signal detail; a signal that could not be evaluated says so instead of disappearing.matched,confidence— legacy registry-match fields kept for compatibility.confidenceis fingerprint match quality, never an AI-likelihood estimate.503 VERIFICATION_UNAVAILABLE— verification could not run; retry. Never a degraded verdict.
Analyze
/v1/analyzeDeprecatedAPI keyOpt-in estimate — REST-only, not in any SDK
Analyze returns a classifier estimate of AI likelihood. It is a guess by design, kept out of/v1/verify verdicts entirely — and deliberately absent from both SDKs, so a probability is never one autocomplete away from the evidence-first API. Call it directly over HTTP if you explicitly want the estimate.curl -X POST https://api.0byte.tech/v1/analyze \
-H "Authorization: Bearer 0b_key_..." \
-H "Content-Type: application/json" \
-d '{ "content": "<base64_encoded_image>", "content_type": "image/png" }'Get proof
/v1/proofs/:idPublicFetch a proof record by its ID. Everything an independent verifier needs is in the response — including which signing key produced the signature and which canonical format it covers.
curl https://api.0byte.tech/v1/proofs/0b_a1b2c3d4-...API keys
/v1/keysAPI keyList and revoke the keys in your account. Minting happens in the terminal — zerobyte login runs a device-code flow you approve in the browser; there is no creation endpoint, so a raw key never transits anything but your own CLI.
GET /v1/keys— list your account's active keys (raw keys are never returned).DELETE /v1/keys/:id— revoke a key immediately (not the one making the request). Proofs it already stamped keep verifying.
Transparency
/v1/transparency/*PublicIndependently confirm that a proof was committed to the signed log and that history was never rewritten.
Signed tree head — GET /v1/transparency/head
curl https://api.0byte.tech/v1/transparency/headInclusion proof — GET /v1/transparency/inclusion/:id
curl https://api.0byte.tech/v1/transparency/inclusion/0b_a1b2c3d4-...Consistency proof — GET /v1/transparency/consistency/:from/:to
curl https://api.0byte.tech/v1/transparency/consistency/64/128Check it with the algorithm in RFC 9162 §2.1.4.2 — proves the log at size 64 is a strict prefix of the log at size 128, i.e. history was never rewritten.
Signing keys — GET /v1/transparency/keys
curl https://api.0byte.tech/v1/transparency/keys404 NOT_YET_ANCHORED.Drop it into your pipeline
Stamping is one authenticated POST after generation, wherever your stack runs it. Each recipe below is complete: an Idempotency-Key per asset makes retries safe against the append-only log, and the response's binding field says exactly what you got — publish stamped_content when it is present.
# pip install fastapi httpx uvicorn
# The stamp() call is complete — wire it to your existing generation code.
import base64, os, uuid
import httpx
from fastapi import FastAPI
app = FastAPI()
ZB = httpx.Client(
base_url="https://api.0byte.tech",
headers={"Authorization": f"Bearer {os.environ['ZEROBYTE_API_KEY']}"},
timeout=30,
)
def stamp(image_bytes: bytes, *, model: str, asset_id: str) -> dict:
"""Call right after your model returns an image. Returns the bytes to
publish (credentialed when signing is enabled) and the public proof URL.
The Idempotency-Key makes retries safe: same asset, same proof."""
r = ZB.post(
"/v1/stamp",
headers={"Idempotency-Key": asset_id},
json={
"content": base64.b64encode(image_bytes).decode(),
"content_type": "image/png",
"provider": "acme-ai",
"model": model,
},
)
r.raise_for_status()
proof = r.json()
publish = (
base64.b64decode(proof["stamped_content"])
if proof.get("stamped_content")
else image_bytes # binding: registry_only — the proof still stands
)
return {"bytes": publish, "verify_url": proof["verify_url"], "id": proof["id"]}
@app.post("/generate")
def generate(prompt: str):
image_bytes = my_model.generate(prompt) # ← your existing call
# Mint one id per generated asset and store it alongside the asset:
# retrying with the same id replays the same proof; deriving it from
# the prompt would collide when two generations share a prompt.
asset_id = uuid.uuid4().hex
result = stamp(image_bytes, model="imagen-x", asset_id=asset_id)
return {"proof": result["verify_url"]}Verify the log yourself
Fetching is not verifying. This is a complete independent verifier — it checks the Ed25519 signature on the head, recomputes the leaf hash from the public proof record, and folds the Merkle audit path up to the signed root. If any line lies, an assert fails.
# pip install requests cryptography
import base64, hashlib, requests
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
API = "https://api.0byte.tech"
proof_id = "0b_..."
# 1 · Signed head: check the key derives its key_id, then check the signature
head = requests.get(f"{API}/v1/transparency/head").json()
pub = bytes.fromhex(head["public_key"])
assert hashlib.sha256(pub).hexdigest()[:8] == head["key_id"]
vk = Ed25519PublicKey.from_public_bytes(pub)
sth0 = f'sth:v1:{head["key_id"]}:{head["tree_size"]}:{head["root_hash"]}'
vk.verify(base64.b64decode(head["signature"]), sth0.encode())
# 2 · Inclusion proof: it carries its own signed (tree_size, root_hash) pair
inc = requests.get(f"{API}/v1/transparency/inclusion/{proof_id}").json()
assert inc["key_id"] == head["key_id"] # else resolve via /v1/transparency/keys
sth = f'sth:v1:{inc["key_id"]}:{inc["tree_size"]}:{inc["root_hash"]}'
vk.verify(base64.b64decode(inc["signature"]), sth.encode())
# 3 · Recompute the leaf hash from the PUBLIC proof record — no trust needed
proof = requests.get(f"{API}/v1/proofs/{proof_id}").json()
leaf = hashlib.sha256(b"\x00" + proof_id.encode()
+ proof["fingerprint"].encode() + proof["timestamp"].encode()).digest()
assert leaf.hex() == inc["leaf_hash"]
# 4 · Fold the audit path (RFC 9162 §2.1.3.2) up to the signed root
f, s, r = inc["leaf_index"], inc["tree_size"] - 1, leaf
for p in [bytes.fromhex(h) for h in inc["inclusion_path"]]:
if f & 1 or f == s:
r = hashlib.sha256(b"\x01" + p + r).digest()
while f & 1 == 0 and f != 0:
f >>= 1; s >>= 1
else:
r = hashlib.sha256(b"\x01" + r + p).digest()
f >>= 1; s >>= 1
assert s == 0 and r.hex() == inc["root_hash"]
print("inclusion verified against a signed head — the math, not our word")- Leaf hash:
SHA256(0x00 ‖ proof_id ‖ fingerprint ‖ timestamp); interior nodes are domain-separated with0x01. - Head signature covers
sth:v1:{key_id}:{tree_size}:{root_hash}, andkey_idis the first 4 bytes ofSHA256(public_key)— derivable, not trusted. - The path-folding algorithm is RFC 9162 §2.1.3.2 — the same one Certificate Transparency uses.
- Key trust: deriving
key_idproves the served key matches its identifier — not that it belongs to the log operator. Pin a known key, or compare/v1/transparency/keysacross time to detect substitution.
Compliance mapping
The EU AI Act's transparency obligations (Article 50) require providers of generative AI systems to mark outputs as artificially generated in a machine-readable way, and for that marking to be detectable. Here is how each requirement maps to what a stamp gives you:
| Requirement | What 0byte provides |
|---|---|
| Machine-readable marking | A standard C2PA manifest embedded at generation, declaring AI origin via the IPTC trainedAlgorithmicMedia source type — readable by any C2PA validator. |
| Marking that survives redistribution | The derived fingerprint in the public registry — origin remains recoverable after screenshots, re-encoding, and metadata stripping. |
| Detectability | The free /v1/verify endpoint and verify UI — anyone can check, no key, no account. |
| Auditability | An RFC 6962-style signed transparency log with public inclusion and consistency proofs — verifiable without trusting 0byte. |
Capability mapping, not legal advice
This maps product capabilities to regulatory language. Whether your deployment satisfies a given obligation depends on your system and jurisdiction — confirm with counsel.Error codes
Errors return {"code", "message", "docs_url", "request_id"} with the matching HTTP status; 415s also carry supported, the list of media types that would have been accepted. Every response — errors included — carries an X-Request-Id header: yours, echoed, when it matches [A-Za-z0-9._-]{1,128}; otherwise a generated one. Quote it when you write in.
| Code | Status | Meaning |
|---|---|---|
| AUTH_MISSING | 401 | No Authorization header |
| AUTH_INVALID | 401 | API key is invalid or revoked |
| SCOPE_DENIED | 403 | Key lacks the required scope |
| KEY_NOT_ACCOUNT_BOUND | 403 | Key management needs an account-bound key |
| RATE_LIMITED | 429 | Too many requests for your tier |
| INVALID_INPUT | 400 | A request field failed validation |
| INVALID_BASE64 | 400 | Content is not valid base64 |
| INVALID_JSON | 400 | Body is not the JSON this endpoint expects (malformed, missing or mistyped field) |
| NOT_FOUND | 404 | No such route — the API lives under /v1 |
| INVALID_IMAGE | 400 | Content is not a decodable image |
| UNSUPPORTED_MEDIA_TYPE | 415 | content_type is outside the supported list (carried in the response) |
| CONTENT_TYPE_MISMATCH | 415 | content_type is supported but the bytes are another format |
| IDEMPOTENCY_KEY_INVALID | 400 | Idempotency-Key must be 1–255 printable characters |
| IDEMPOTENCY_KEY_REUSED | 409 | Idempotency-Key already used with a different request |
| IDEMPOTENCY_IN_PROGRESS | 409 | The original request with this key is still running — retry shortly |
| PAYLOAD_TOO_LARGE | 413 | Content exceeds the 10 MB limit |
| CONTENT_TOO_UNIFORM | 422 | Image has no perceptual content to fingerprint (blank or near-blank) |
| PROOF_NOT_FOUND | 404 | No proof with that ID |
| NOT_YET_ANCHORED | 404 | Proof not yet in the log (wait ~60s) |
| KEY_NOT_FOUND | 404 | No API key with that ID |
| CANNOT_DELETE_SELF | 400 | Can’t revoke the key making the request |
| VERIFICATION_UNAVAILABLE | 503 | Verification could not run — retry; never a degraded verdict |
| IP_RATE_LIMITED | 429 | Too many requests from this address (60-burst per IP, 1/s refill) — honour Retry-After |
| LAST_KEY | 400 | Cannot revoke the last key on the account — create another first |
| AUTHORIZATION_PENDING | 202 | CLI pairing: not approved yet, keep polling |
| SLOW_DOWN | 429 | CLI pairing: polling faster than the given interval |
| PAIRING_NOT_FOUND | 404 | CLI pairing: unknown code |
| PAIRING_EXPIRED | 410 | CLI pairing: expired — run login again |
| PAIRING_UNUSABLE | 409 | CLI pairing: code mistyped, expired, or already approved |
| PAIRING_ALREADY_USED | 409 | CLI pairing: key already collected — run login again |
| PAIRING_RATE_LIMITED | 429 | CLI pairing: too many logins from this address |
| INTERNAL_ERROR | 500 | Something failed on our side — quote the request_id |
Ready to ship Content Credentials?
Get an API key and stamp your first generation in an afternoon.

