Skip to content

Declarations

For every upload with a usable io.iscc.v0 soft binding, the store declares the content on the ISCC Discovery Protocol under its own key, with the manifest address as gateway URL. Anyone who later finds the content through the ISCC network is led to the manifest.

Identity

The store signs as did:web:<host>, where <host> is the host of ISCC_C2PA_STORE_URL (a port is percent-encoded, as did:web requires). Its DID document, with the Ed25519 public key, is served at /.well-known/did.json:

{
  "id": "did:web:c2pa-store-test.iscc.io",
  "verificationMethod": [
    {
      "id": "did:web:c2pa-store-test.iscc.io#z6MkkArwN9vYBX3y1XQGo67D21UuvMd4m8sBM4r6mPbq9Fjg",
      "type": "Multikey",
      "controller": "did:web:c2pa-store-test.iscc.io",
      "publicKeyMultibase": "z6MkkArwN9vYBX3y1XQGo67D21UuvMd4m8sBM4r6mPbq9Fjg"
    }
  ],
  "authentication": [
    "did:web:c2pa-store-test.iscc.io#z6MkkArwN9vYBX3y1XQGo67D21UuvMd4m8sBM4r6mPbq9Fjg"
  ],
  "assertionMethod": [
    "did:web:c2pa-store-test.iscc.io#z6MkkArwN9vYBX3y1XQGo67D21UuvMd4m8sBM4r6mPbq9Fjg"
  ]
}

The document is abbreviated here; it also lists the key under capabilityDelegation and capabilityInvocation. Declarations are enabled when the deployment has both a secret key (ISCC_C2PA_STORE_SECKEY) and a hub (ISCC_C2PA_STORE_HUB_URL). Without them the store hosts and finds manifests only, every upload gets the status disabled, and /.well-known/did.json answers 404.

What gets declared

The units come from the io.iscc.v0 soft bindings of the active manifest that describe the whole asset, as the store indexed them (256-bit Version 0 units, Meta-Codes left out). A block with a scope, a region or time span of the asset, is searchable on the store but never declared, because a declaration stands for the whole content. The store takes the first unit of each MainType, in the order Semantic-, Content-, Data-, Instance-Code:

  • a 256-bit Data-Code and Instance-Code are required (IEP-0020); without them the declaration is skipped;
  • a Semantic-Code whose SubType differs from the Content-Code's is left out, because an ISCC-CODE cannot combine them.

The IsccNote

{
  "$schema": "http://purl.org/iscc/schema/iscc-note-0.8.0.json",
  "iscc_code": "ISCC:KAA2RASIYU5IKLENAW2B6NDSJEZCLJJCV5FCZHF7NI",
  "datahash": "1e20a522af4a2c9cbf6a808924afd2acfa0793347b936678f5982e22fdd3dd4a0e37",
  "nonce": "0010dc860d4568c8ae7ac3e8ed3b2946",
  "timestamp": "2026-10-09T12:00:00.000Z",
  "gateway": "https://c2pa-store-test.iscc.io/v1/manifests/urn%3Ac2pa%3AF9168C5E-CEB2-4FAA-B6BF-329BF39FA1E4",
  "units": [
    "ISCC:EAD2RASIYU5IKLENP2OFI4CHZGRWYQCSW2WKX3Y6FJGOCXSYNYGLGBI",
    "ISCC:GADQLNA7GRZESMRF2J7NZPNWGI3II2ST5YUN5SS6GVQ2ZQGJXPPYDNI"
  ],
  "signature": {
    "version": "ISCC-SIG v1.0",
    "controller": "did:web:c2pa-store-test.iscc.io",
    "pubkey": "z6MkkArwN9vYBX3y1XQGo67D21UuvMd4m8sBM4r6mPbq9Fjg",
    "proof": "z3LNSaxb..."
  }
}

The example declares the Content-, Data- and Instance-Code of the API reference; the proof is shortened.

Field Value
$schema IsccNote schema 0.8.0
iscc_code The ISCC-CODE composed from all declared units
datahash 1e20 followed by the hex body of the Instance-Code
nonce A fresh nonce whose first 12 bits name the hub of the declaration
timestamp Signing time, RFC 3339 UTC with milliseconds
gateway The manifest address, with the manifest ID percent-encoded
units The declared units without the Instance-Code
signature ISCC signature (IEP-0019) by the store's key

The datahash is a BLAKE3 multihash of the asset. The store never sees the asset, but it does not need to: the body of a 256-bit Instance-Code is the BLAKE3 digest of the asset, so the multihash prefix 1e20 (BLAKE3, 32 bytes) and the Instance-Code body give the same value.

The note goes to POST {hub}/declaration with the header X-Force-Declaration: true. Others may have declared the same content before, which the ISCC Discovery Protocol allows, and the store declares its own manifest address regardless.

Each declaration remembers its hub and HUB-ID (ISCC_C2PA_STORE_HUB_URL and ISCC_C2PA_STORE_HUB_ID when the upload was stored). Retries and the withdrawal go to that hub with nonces for it, so a later change of the configured hub does not strand earlier declarations.

Statuses

The status is part of every upload answer (declaration.status) and of the operator dashboard.

Status Meaning
declared The hub accepted the note; isccId is the ISCC-ID of the declaration
pending Hub unreachable, server error (5xx), 408, 425, 429, or 201 without an ISCC-ID; the store retries
failed The hub refused the note (any other non-201 answer), or the retries gave up; the error is kept for the operator
skipped No 256-bit Data-Code and Instance-Code in a whole-asset io.iscc.v0 binding, or the upload sent declare=false
disabled The deployment has no declaration key or no hub
withdrawn The manifest was deleted, and the declaration was removed from the hub or had no ISCC-ID to remove

The first attempt happens during the upload, after the manifest is stored, so a hub that is down never loses a manifest. Uploading the same bytes again returns the stored result and does not declare again.

Retries

The container runs python manage.py retry_declarations every ISCC_C2PA_STORE_RETRY_SECONDS (120 by default). Each round sends every due pending declaration of a live manifest to the hub again, and the withdrawal of every due declaration that is still declared although its manifest was deleted.

Retries back off. A declaration is due once a wait has passed since its last change: 60 seconds, doubled with every hub request it has made, at most one hour. The 60 seconds also keep a round from sending a note that an upload or another round is still sending. A successful declaration starts counting again from zero for its withdrawal. Declarations held up by a hub outage are sent within about an hour of the hub's return.

A declaration still pending 7 days after its manifest was uploaded becomes failed with the error Gave up after 7 days. Withdrawals never give up: one the hub keeps refusing stays declared with its last error and is tried again with the same backoff, at the longest once an hour.

The Declarations page of the operator dashboard (/admin/) has an action for the declarations that need attention: it makes selected failed declarations pending again and sends them to the hub at once, sends selected pending ones, and withdraws those still declared for a deleted manifest.

GET /v1/services/status reports degraded while any declaration of a manifest uploaded more than 15 minutes ago is still pending. It measures the age of the upload, not how long the declaration has been pending: a failed declaration that an operator sends again turns the status degraded at once if the hub is still unavailable.

Deletes withdraw

A delete, through DELETE /v1/manifests/{manifestId} with the operator token or the delete action of the operator dashboard, removes the bytes and the index entries and keeps a tombstone: the address answers 410, and neither the same bytes nor the same manifest ID can be uploaded again (409) until an operator releases the ID.

Status before the delete What happens
declared A signed IsccNoteDelete goes to DELETE {hub}/declaration/{iscc_id}; then withdrawn
pending or failed Becomes withdrawn in the same transaction as the delete, without contacting the hub
any other Unchanged

The hub's 204, or 404 for a declaration it no longer has, counts as withdrawn. If the hub cannot be reached or refuses, or the store no longer has its declaration key, the declaration stays declared with the error, and the retry loop sends the withdrawal again.

A pending declaration is not always absent from the hub:

  • A request may be in flight while the manifest is deleted. If the hub accepts it, the store records the declaration as declared all the same, and the retry loop withdraws it.
  • The hub may have accepted an earlier note whose answer never arrived: a timeout, or a 201 without an ISCC-ID. The store does not learn the ISCC-ID of such a note, so it can neither record nor withdraw it. The retry sends a fresh note, which the hub may accept as a second declaration, and after a delete the unrecorded note stays on the hub with a gateway URL that answers 410.

Releasing a manifest ID

A tombstone keeps its manifest ID taken. An operator who deleted a manifest uploaded under someone else's manifest ID can give the ID back with the dashboard action "Release the IDs of deleted manifests for new uploads". It removes the tombstone and its declaration record: the address then answers 404, and the ID, as well as the deleted bytes, can be uploaded again. The action skips manifests that are not deleted and those whose declaration is still declared, because the store would lose track of a declaration it still has to withdraw.

Declaring it yourself

Uploaders that declare the content under their own identity send declare=false:

curl -X POST "https://c2pa-store-test.iscc.io/v1/manifests?declare=false" \
    -H "Content-Type: application/c2pa" \
    --data-binary @photo.c2pa

The answer has the status skipped and the manifestUrl. Use that address as the gateway URL of your own declaration; ISCC C2PA resolvers then find the manifest through your declaration, as their gateway contract describes.