Trust and proofs

What each proof establishes, where to read it, and how a third party redoes the verification of the anchoring, the IPFS copy and the signature without trusting us.

On this page

By the end of this page, you will know which proofs come with a product and its passport, what each one establishes exactly, what none of them establishes, and how a third party redoes the verification itself, with its own tools.

All of the addresses cited here are public and require no API key. Each one exists in two forms, with the /v1 prefix and without a prefix. The examples use /v1, which is the form to prefer.

#The principle

A proof is only worth something if it can be redone without its issuer. Two of the elements described here hold outside our servers: the IPFS copy and the anchoring.

The IPFS copy is addressed by its content: its address is the result of hashing the file itself, so modifying the file changes the address.

The anchoring is a record written to a public chain, Base, chain_id 8453. Anyone can read it on a block explorer or through a node.

We sign the attestation with a key of which we publish only the public part. You verify the signature against that public key. By default, we publish that key on our API. A brand that hosts its issuer identifier on its own domain also moves this step off our servers.

The rest, the SHA-256 hash of the content and the version seal, are consistency checks that we compute. They are useful, and this page states precisely how far they reach.

#What each proof establishes

ProofWhat it establishesWhat it leaves open
SHA-256 hash of the content (data_hash)the recorded content matches the recorded hashboth values are held by us, this is an internal consistency check
Version seal (seal)a version that was published and then modified detaches from the chain of following versionsgives no date that can be held against a third party
IPFS copy (ipfs_uri)the published document is frozen: its address is its hashsays nothing about the publication date
Document anchoring (passport_anchor)this passport content existed no later than the transactiondoes not give back the exact content
Item anchoring (anchor)this item belongs to a batch whose root is recorded on Basesays nothing about the passport's content
Signed attestation (vc)the document was indeed issued by the announced brandsays nothing about the accuracy of the data described

The two mechanisms have the same shape and never cross.

The two anchorings: the batch of items and the passport version

Two separate columns, one per mechanism. On the left, the anchoring of a batch of items: an item's leaf brings together its token identifier, the hash of its unit identifier and the hash of the address of its metadata. On the right, the anchoring of a passport version: the leaf brings together the passport identifier, the version number and the SHA-256 hash of the content. On each side, the leaves form a tree, the tree yields a root, and that root goes out in a transaction on Base, chain_id 8453. The first one is read on GET /v1/verify/merkle/{identifier} and in the anchor block, the second in the passport_anchor block of GET /v1/passport/{identifier}/proof. The two columns never meet: no record made for the item covers the passport's content.

The exact composition of an item's leaf follows the contract on which its batch was minted, and you do not have to reconstruct it: the endpoint GET /v1/verify/merkle/{identifier} gives you leaf and proof ready to use, and the contract can recompute membership itself with verifyAnchored. The leaf of a passport version, for its part, is described field by field further down, along with the code that recomputes it.

#Where to read every proof at once

The endpoint is GET /v1/passport/{identifier}/proof. It gathers the hash, the IPFS copy, both anchorings, the seal and the state of the attestation.

The identifier field accepts the UID hash (0x followed by 64 hexadecimal characters), the token identifier (a string of digits) or the serial number printed on the label. A certificate number is not resolved by this address and returns 404. For a certificate number, go through GET /v1/verify/merkle/{identifier} or through /resolve.

curl "https://api.sealtrust.io/v1/passport/000000000000/proof"
200 OK
{
  "passport_version": 3,
  "data_hash": "0000000000000000000000000000000000000000000000000000000000000000",
  "ipfs_uri": "ipfs://bafyexemple0000000000000000000000000000000000000000000",
  "ipfs_gateway_url": "https://ipfs.io/ipfs/bafyexemple0000000000000000000000000000000000000000000",
  "anchor": {
    "chain": "base",
    "chain_id": 8453,
    "type": "merkle_batch",
    "tx_hash": "0x5555555555555555555555555555555555555555555555555555555555555555",
    "basescan_url": "https://basescan.org/tx/0x5555555555555555555555555555555555555555555555555555555555555555",
    "merkle_root": "0x4444444444444444444444444444444444444444444444444444444444444444",
    "anchored": true,
    "proves": "batch_inclusion"
  },
  "passport_anchor": {
    "chain": "base",
    "chain_id": 8453,
    "tx_hash": "0x6666666666666666666666666666666666666666666666666666666666666666",
    "basescan_url": "https://basescan.org/tx/0x6666666666666666666666666666666666666666666666666666666666666666",
    "merkle_root": "0x7777777777777777777777777777777777777777777777777777777777777777",
    "leaf": "0x1111111111111111111111111111111111111111111111111111111111111111",
    "leaf_index": 0,
    "proof": [
      "0x2222222222222222222222222222222222222222222222222222222222222222",
      "0x3333333333333333333333333333333333333333333333333333333333333333"
    ],
    "anchored_at": "2026-08-01T10:00:00+00:00",
    "data_hash_matches": true,
    "proves": "content_existed_at_or_before_tx"
  },
  "seal": {
    "sealed": true,
    "sealed_at": "2026-08-01T09:00:00+00:00",
    "algorithm": "st-dpp-chain-v1",
    "version_hash": "0000000000000000000000000000000000000000000000000000000000000000",
    "prev_version_hash": "0000000000000000000000000000000000000000000000000000000000000000",
    "linked": true,
    "chain_link_match": true
  },
  "vc": {
    "issued": true,
    "vct": "https://schema.sealtrust.io/vct/digital-product-passport",
    "issued_at": "2026-08-01T09:00:00+00:00"
  },
  "verifications": {
    "count": 12,
    "last_verified_at": "2026-08-12T14:32:00+00:00"
  }
}

Every value in this example is fictitious.

A passport attached to a model has its own summary, at GET /v1/passport/01/{gtin}/proof. Two blocks are missing from it, and that absence is the correct answer: anchor dates an item, and a model is not one, and verifications counts UID verifications, and a model has no UID. The response then carries "level": "model" and the GTIN.

#Verifying the IPFS copy

We deposit the copy on IPFS at publication time. Since the switch to depositing the public projection, this copy contains only the fields of the public tier. For older passports, we serve the link only if the deposited content matches that projection.

Two things are within your reach.

Retrieve the copy. The ipfs_gateway_url field gives a direct link. The ipfs_uri field gives the content identifier, which you can open through the gateway of your choice or through your own IPFS node. Do not depend on the gateway we indicate.

Compare over time. The content identifier is the result of hashing the file. Two retrievals of the same identifier return the same bytes, otherwise the identifier would have changed.

The identifier lets you prove later that the copy you hold is indeed the one that was published. It does not guarantee that the file will still be served by a gateway. We announce no retention period. Keep the content identifier and a copy of the bytes you read.

The endpoint GET /v1/passport/{identifier}/verify makes this comparison for you and returns ipfs_match. Three values, three meanings: true the copy matches, false it differs, null it could not be retrieved. An unreachable gateway gives null, never false.

curl
curl "https://api.sealtrust.io/v1/passport/000000000000/verify"

The same response carries db_hash_match, which compares a piece of data we hold to a hash we hold, and the seal block, whose chain_link_match at false signals that a sealed version was modified after publication.

#Verifying the anchoring

Anchoring rests on a Merkle tree. We reduce each document to be dated to a hash, called a leaf. We combine the leaves two by two up to a single value, the root. We record the root alone on the chain. A membership proof gives you the list of neighboring hashes that let you climb from a leaf up to the root.

You do not have to trust us in order to conclude. The starting values come from our response, and the final comparison is made against the chain, which we do not control. Write down those values the day you read them: they are then enough to redo the verification without calling us again.

#Step 1: recompute the root

Take leaf and proof from the passport_anchor block. At each step, the two values are placed in ascending order before being concatenated and hashed with keccak256, the hash function used by the chain. The final result must equal merkle_root.

The two examples below require a library that computes keccak256: eth-utils in Python, ethers in TypeScript.

from eth_utils import keccak


def racine_depuis_preuve(feuille: str, preuve: list[str]) -> str:
    courant = bytes.fromhex(feuille[2:])
    for voisin in preuve:
        autre = bytes.fromhex(voisin[2:])
        gauche, droite = (courant, autre) if courant < autre else (autre, courant)
        courant = keccak(gauche + droite)
    return "0x" + courant.hex()


print(
    racine_depuis_preuve(
        "0x1111111111111111111111111111111111111111111111111111111111111111",
        [
            "0x2222222222222222222222222222222222222222222222222222222222222222",
            "0x3333333333333333333333333333333333333333333333333333333333333333",
        ],
    )
)

#Step 2: compare the root to the chain

Open basescan_url. The transaction emits a RootAnchored event, which carries four values: the batch identifier, the root, the number of leaves and a metadata hash. Compare the root in the event to the one you have just recomputed.

If the two match, the root we served you is indeed the one recorded on Base, as of the date of the block that contains the transaction. That date is the only element the chain adds, and it is the one that neither the hash nor the IPFS copy can give.

The leaf of a passport version is the keccak256 hash of three values encoded in the ABI format, the binary layout expected by a contract on the chain: passport_id as uint256, passport_version as uint256, and data_hash as bytes32.

data_hash is returned without the 0x prefix: read it as 32 hexadecimal bytes.

from eth_abi import encode
from eth_utils import keccak

passport_id = 7
passport_version = 3
data_hash = "0000000000000000000000000000000000000000000000000000000000000000"

feuille = "0x" + keccak(
    encode(
        ["uint256", "uint256", "bytes32"],
        [passport_id, passport_version, bytes.fromhex(data_hash)],
    )
).hex()
print(feuille)

The values in this example are fictitious. Compare the leaf you obtain to the leaf field of the passport_anchor block.

passport_version appears in the proof summary. So does data_hash. passport_id is the id field of the response of GET /v1/passport/{identifier}, or of GET /v1/passport/01/{gtin} for a model passport. Both responses are public and exist as soon as a version is published. The version number is part of the leaf so that a proof designates one precise version, even if two versions carried identical data.

The data_hash_matches field says whether the hash recorded today is the one that was written on chain. The value false means that the recorded data no longer matches what was anchored. That is the signal this mechanism exists to raise, and it is published.

#Verifying that an item belongs to an anchored batch

The endpoint is GET /v1/verify/merkle/{identifier}. It returns the proof that an item belongs to the batch whose root was recorded on Base. Here, identifier accepts the UID hash, the token identifier, the serial number printed on the label or a certificate number.

curl
curl "https://api.sealtrust.io/v1/verify/merkle/000000000000"
200 OK
{
  "anchor_batch_id": 42,
  "merkle_root": "0x4444444444444444444444444444444444444444444444444444444444444444",
  "leaf": "0x1111111111111111111111111111111111111111111111111111111111111111",
  "leaf_index": 0,
  "proof": [
    "0x2222222222222222222222222222222222222222222222222222222222222222",
    "0x3333333333333333333333333333333333333333333333333333333333333333"
  ],
  "anchor_tx_hash": "0x5555555555555555555555555555555555555555555555555555555555555555",
  "basescan_url": "https://basescan.org/tx/0x5555555555555555555555555555555555555555555555555555555555555555",
  "contract_address": "0x0000000000000000000000000000000000000000",
  "network": "Base",
  "token_id": "1",
  "verified": true,
  "lifecycle_status": "mined",
  "withdrawn": false,
  "leaf_set": "frozen"
}

The proof is recomputed with the same function as above. Two further verifications are possible on the chain, on the contract whose address is given by contract_address.

Read on the contractWhat it returns
anchoredRootByBatch(anchor_batch_id)the root recorded for this batch, to be compared to merkle_root
verifyAnchored(anchor_batch_id, token_id, proof)a boolean: the chain recomputes membership itself

Both functions are read-only and open to everyone. The verified field of the response is the result of that same read performed by us. It is null when the read could not complete, which takes nothing away from the local proof.

Three fields call for careful reading.

leaf_set is frozen when the set of leaves used is the one that was recorded at anchoring time. It is recomputed for batches anchored before that record existed: the tree is then rebuilt from the batch's current rows, and a later modification of the batch can make it diverge from the recorded root.

lifecycle_status and withdrawn describe the item's state today. An anchoring asserts that a given set of leaves yielded that root on that day, and that remains true whatever happens afterwards. The proof of an item withdrawn from the catalog therefore continues to verify. These two fields exist so that you do not have to guess the current state from a valid proof.

#Verifying the attestation's signature

Publishing a passport triggers the issuance of a signed attestation, in the SD-JWT-VC format. It establishes that the document comes from the announced brand. If issuance fails, publication still completes and the passport remains without an attestation.

The path that does not depend on us takes four moves.

Retrieve the attestation with GET /v1/passport/{identifier}/vc. The response carries sd_jwt_vc, the presentation itself, and issuer, the issuer's identifier in did:web format.

Retrieve the issuer's document. An identifier of the form did:web:<domain> resolves to https://<domain>/.well-known/did.json. An identifier of the form did:web:<host>:brand:<number> resolves to https://<host>/brand/<number>/did.json. The document lists the brand's non-revoked public keys, each one in JsonWebKey2020 format.

Choose the right key. The attestation's header carries a kid field of the form <did>#key-<version>. Take the key that carries that identifier in the document. This numbering lets an old attestation stay verifiable after a key rotation, as long as the old key is not revoked.

Verify the signature. The algorithm is ES256. The type declared in the header is dc+sd-jwt. Any standard did:web and SD-JWT-VC library will do.

curl
curl "https://api.sealtrust.io/v1/passport/000000000000/vc"

A brand can host its issuer identifier on its own domain. It then keeps ownership of its issuer identity, and verifying its attestations no longer goes through our servers.

If you prefer a direct answer, GET /v1/passport/{identifier}/vc/verify does the verification and returns verified. On failure, verified is false and error is verification_failed, without the original message.

#When a proof is missing

A missing proof is information. We return it as such.

SituationWhat you observeCorrect reading
Version published recentlypassport_anchor missinganchoring a passport is an operation that SealTrust triggers by hand, and many passports are never anchored. Do not expect it. The version seal holds integrity without it
The item's batch was never anchoredanchor.anchored at false, type at mint_transaction, proves at token_mintedthe transaction proves the token exists. It says nothing about the batch or the passport
Item never recorded on the chainanchor block missing entirelythe item has neither an anchored batch nor a mint transaction. Read passport_anchor, which dates the document and does not depend on the item
IPFS deposit failed at publicationipfs_uri missingpublication still completed, the passport remains without an IPFS copy
IPFS gateway unreachableipfs_match at null, ipfs_uri and ipfs_gateway_url missingunknown. Try again later, or through another gateway
Passport published before the version chain existedseal.linked at false, reason at sealed_before_chainno link is manufactured after the fact
No attestation issuedvc.issued at falserepublish the passport to trigger issuance
Batch modified since it was anchoredGET /v1/verify/merkle/{identifier} answers 409the recomputed root differs from the recorded root. We refuse to serve a proof that would fail on the chain

#Rate limits and errors

Addresses starting with /passport share a limit of 60 calls per 60-second window and per IP address. Those starting with /verify share a limit of 30 calls per 60-second window and per IP address. The forms with and without /v1 count on the same counter.

Going over returns 429, with the headers Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. The last three also accompany responses that go through. Read X-RateLimit-Remaining to space out your calls before reaching the limit.

Every response carries Cache-Control: no-store, max-age=0, including the /proof and /verify/merkle proof summaries. Do not put any of these responses behind a shared cache. To reduce the number of your calls, keep the result in your own application cache, with the freshness window your use tolerates.

CodeConditionWhat to do
404no product matches the identifiercheck the identifier
404the product exists, no passport publishedpublish a version
404the item belongs to no anchored batch, on /verify/merklenormal case, do not read it as a failure
404no attestation issued for this passport, on /vcrepublish the passport to trigger issuance
409the batch has changed since it was anchored, on /verify/merklethe proof would be invalid on the chain, contact the brand
429rate limit exceededwait for the duration indicated by Retry-After

#What to remember

Each proof establishes one thing and one thing only, and the response names it. Read anchored, proves, data_hash_matches, chain_link_match and ipfs_match before asserting anything.

What does not depend on us: the IPFS copy, addressed by its content, and the record on Base chain_id 8453, readable by anyone. The attestation's signature is verified against a public key. By default, we publish that key on our API. A brand that hosts its issuer identifier on its own domain also moves this verification off our servers.

What depends on us: the recorded hash and the version seal. These are useful consistency checks, and this page states their exact scope.

An anchoring establishes a date. It does not establish that the dated content is accurate. It does not prevent a brand from publishing a correction, which becomes the next version and leaves the anchored version verifiable.

Your answer opens a pre-filled email in your mail app, addressed to contact@sealtrust.io. You read it over before sending it.

Suggest a correctionReport a problem