Portal
Documentation: all sections

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.

ClaimStatus
This gateway generated these exact bytesProven — signature + hash
Under this model, at this time, for a prompt with this fingerprintProven — the signed manifest says so
The image has not been edited since generationProven — the hash stops matching
The image was not re-encoded, resized, cropped, or screenshottedNot survivable — any of those destroy the manifest and the hash
The image is "safe", "real", or accurately depicts anythingNot claimed at all
C2PA / Content Credentials conformanceNo. 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_sha256 is 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

ContainerCarrier
PNGAn uncompressed iTXt chunk with keyword sable:provenance, inserted immediately before IEND
JPEGA COM segment immediately after SOI, payload prefixed sable:provenance\0
MP4 / WebM / GIFRecognised 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 elseNot 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:

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.