Media provenance
Every image Sable generates comes back with a signed provenance manifest: a small, content-free record of which model produced it, which engine served, when, and the sha256 of the exact bytes — signed with the same secp256k1 / EIP-191 key that signs every Sable receipt.
The manifest is returned in the response, sent as a response header, and embedded inside the image file itself, so the proof travels with the picture after it leaves your process.
The manifest is one shape for every generated asset — kind is image or
video — so a single verifier handles both. Embedding, however, is per
container: PNG and JPEG carry the manifest inside the file; video containers do
not (see below), and say so with
embedded: false rather than pretending otherwise.
What this proves, and what it does not
This is the honest scope. Read it before you rely on it.
| Claim | Status |
|---|---|
| This gateway generated these exact bytes | Proven — signature + hash |
| Under this model, at this time, for a prompt with this fingerprint | Proven — the signed manifest says so |
| The image has not been edited since generation | Proven — the hash stops matching |
| The image was not re-encoded, resized, cropped, or screenshotted | Not survivable — any of those destroy the manifest and the hash |
| The image is "safe", "real", or accurately depicts anything | Not claimed at all |
| C2PA / Content Credentials conformance | No. See below |
This is C2PA-shaped, not C2PA. It is a detached, signed assertion carried inside the image container, which is the same idea — but there is no JUMBF store, no X.509 certificate chain, and no trust list. Trust here reduces to one question: do you trust the Sable signer address? Never describe it as C2PA-compliant or C2PA-conformant, because it is neither.
There is also no negative inference available. An image with no manifest is not evidence of anything: it may never have been Sable-generated, or it may simply have passed through a tool that re-encoded it.
The manifest
{
"spec": "sable-image-provenance:v1",
"kind": "image",
"request_id": "img_1f0c…",
"index": 0,
"model": "sable-image",
"engine": "hidream",
"created_at": "2026-09-04T11:02:13Z",
"asset_sha256": "9f2c…",
"prompt_fp": "4a1b7c2d9e0f3a55",
"container": "png",
"size": "1024x1024",
"gateway_signer": "0x…"
}
spec is sable-image-provenance:v1 or sable-video-provenance:v1, and kind
repeats it as image / video so a reader keying on either agrees. container
is detected from the bytes, never from a declared MIME type. A video manifest
adds duration_secs. Field order is the signing order, so read the manifest as
signed bytes, not as a JSON object you re-serialize.
prompt_fp is a sha256 prefix of the prompt, never the prompt
(§3): it lets you prove which prompt produced an image if you
still hold the prompt, and reveals nothing if you do not. size and seed
appear only when you supplied them.
request_id is the request id of the same call's signed receipt, so a manifest
and a receipt are two independently-signed statements about the same generation.
The receipt also carries image_sha256 (and image_sha256_list when n > 1).
The critical ordering rule
asset_sha256is the hash of the file before the manifest was embedded.
It has to be — the manifest contains the hash, so the hash cannot cover the manifest. Verification therefore strips the manifest chunk back out first, then hashes what remains. If you write your own verifier and skip that step, every image will look tampered with.
Where it lives in the file
| Container | Carrier |
|---|---|
| PNG | An uncompressed iTXt chunk with keyword sable:provenance, inserted immediately before IEND |
| JPEG | A COM segment immediately after SOI, payload prefixed sable:provenance\0 |
| MP4 / WebM / GIF | Recognised but not written. An MP4 uuid box or a Matroska tag is a real format change, and a half-correct one produces files some players refuse — so the manifest travels beside the asset with "embedded": false |
| Anything else | Not embedded. The manifest is still returned in the response body with "embedded": false, and still verifies |
Both carriers are metadata: they change no pixel, and any standard decoder ignores them. Stripping them recovers the original file byte-for-byte.
When embedded is false, nothing was inserted — so the file as served is
the file that was hashed, and verification skips the stripping step. That flag
is the only thing that tells a verifier which case it is in, which is why Sable
never sets it optimistically.
Generating
{
"created": 1788000000,
"data": [{ "b64_json": "iVBORw0KGgo…" }],
"provenance": [
{
"index": 0,
"kind": "image",
"manifest": "eyJzcGVjIjoic2FibGUtaW1hZ2Ut…",
"signature": "0x…",
"signer": "0x…",
"container": "png",
"asset_sha256": "9f2c…",
"embedded": true,
"payload": { "spec": "sable-image-provenance:v1", "…": "…" }
}
],
"sable_receipt": { "receipt": "…", "signature": "0x…", "signer": "0x…" }
}
The b64_json you get back is the image with the manifest embedded. The
response also carries x-sable-provenance (the base64url manifest of the first
image) and x-sable-provenance-sig; for n > 1 the body's provenance array
is authoritative, because a header cannot carry several.
Verifying
There are two ways, and they answer different questions.
1. The manifest signature, through the ordinary receipt endpoint
The manifest is signed exactly like a receipt, so the existing public verify endpoint checks it — no new machinery, no new trust root.
curl -s https://api.buildsable.com/v1/receipts/verify \
-H 'content-type: application/json' \
-d '{"receipt":"<the manifest string>","signature":"0x…"}'That proves the manifest is authentic. It says nothing about whether the image in your hand is the image it describes.
2. The image itself
POST /v1/images/verify is public and does the whole job: it pulls the embedded
manifest out, strips it back out to recover the original bytes, re-hashes them,
and checks the signature — reporting each answer independently, because the
distinctions matter.
curl -s https://api.buildsable.com/v1/images/verify \
-H 'content-type: application/json' \
-d "{\"asset_b64\":\"$(base64 < picture.png | tr -d '\n')\"}"{
"manifest_found": true,
"manifest_embedded": true,
"spec_ok": true,
"asset_kind": "image",
"signature_valid": true,
"hash_matches": true,
"signer": "0x…",
"recovered_address": "0x…",
"container": "png",
"computed_asset_sha256": "9f2c…",
"payload": { "spec": "sable-image-provenance:v1", "…": "…" }
}
Read the fields separately:
manifest_found: false— the file carries no Sable manifest. It was never Sable-generated, or it was re-encoded. Not evidence of forgery.signature_valid: false— the manifest did not come from this gateway's signer. Someone wrote asable:provenancechunk of their own.hash_matches: falsewithsignature_valid: true— the strong signal. Sable really did sign a manifest for an image, and this is not that image any more: it was edited, re-compressed, or the manifest was copied onto a different picture.hash_matches: null— no image was supplied, or the manifest claimed no hash. Honestly unknown, not false.
You can also verify a detached manifest against an asset that no longer
carries one, by passing manifest and signature alongside asset_b64; a
supplied manifest overrides anything embedded. (image_b64 is accepted as an
alias for asset_b64.)
Availability and limits
Provenance is produced for every image returned as b64_json. When a provider
returns a url instead, the gateway never fetches those bytes and so cannot
hash them — that image gets no manifest, and the provenance array simply has
no entry for it.
Image generation itself is config-gated; the verify
endpoint is always available, public, and per-IP rate limited. It caps decoded
assets at 6 MB, and the gateway's own request-body limit applies first — with
the default 4 MB body limit, base64 expansion puts the practical ceiling around
3 MB. Anything larger is still verifiable offline: strip the manifest (only for
PNG/JPEG — for everything else there is nothing to strip), hash the file
yourself, and compare against asset_sha256, then check the signature at
POST /v1/receipts/verify.