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
- The principle
- What each proof establishes
- Where to read every proof at once
- Verifying the IPFS copy
- Verifying the anchoring
- Step 1: recompute the root
- Step 2: compare the root to the chain
- Step 3: link the root to the content
- Verifying that an item belongs to an anchored batch
- Verifying the attestation's signature
- When a proof is missing
- Rate limits and errors
- What to remember
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
| Proof | What it establishes | What it leaves open |
|---|---|---|
SHA-256 hash of the content (data_hash) | the recorded content matches the recorded hash | both 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 versions | gives no date that can be held against a third party |
IPFS copy (ipfs_uri) | the published document is frozen: its address is its hash | says nothing about the publication date |
Document anchoring (passport_anchor) | this passport content existed no later than the transaction | does not give back the exact content |
Item anchoring (anchor) | this item belongs to a batch whose root is recorded on Base | says nothing about the passport's content |
Signed attestation (vc) | the document was indeed issued by the announced brand | says nothing about the accuracy of the data described |
The two mechanisms have the same shape and never cross.
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"const reponse = await fetch(
"https://api.sealtrust.io/v1/passport/000000000000/proof",
);
const preuves = await reponse.json();
console.log(preuves.anchor?.anchored, preuves.passport_anchor?.data_hash_matches);import requests
reponse = requests.get(
"https://api.sealtrust.io/v1/passport/000000000000/proof",
timeout=10,
)
reponse.raise_for_status()
preuves = reponse.json()
print(preuves.get("anchor", {}).get("anchored"))
print(preuves.get("passport_anchor", {}).get("data_hash_matches")){
"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 "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",
],
)
)import { keccak256 } from "ethers";
function racineDepuisPreuve(feuille: string, preuve: string[]): string {
let courant = feuille.toLowerCase();
for (const voisin of preuve) {
const autre = voisin.toLowerCase();
courant =
courant < autre
? keccak256("0x" + courant.slice(2) + autre.slice(2))
: keccak256("0x" + autre.slice(2) + courant.slice(2));
}
return courant;
}
console.log(
racineDepuisPreuve(
"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.
#Step 3: link the root to the content
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)import { AbiCoder, keccak256 } from "ethers";
const passportId = 7;
const passportVersion = 3;
const dataHash =
"0000000000000000000000000000000000000000000000000000000000000000";
const feuille = keccak256(
AbiCoder.defaultAbiCoder().encode(
["uint256", "uint256", "bytes32"],
[passportId, passportVersion, "0x" + dataHash],
),
);
console.log(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 "https://api.sealtrust.io/v1/verify/merkle/000000000000"{
"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 contract | What 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 "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.
| Situation | What you observe | Correct reading |
|---|---|---|
| Version published recently | passport_anchor missing | anchoring 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 anchored | anchor.anchored at false, type at mint_transaction, proves at token_minted | the transaction proves the token exists. It says nothing about the batch or the passport |
| Item never recorded on the chain | anchor block missing entirely | the 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 publication | ipfs_uri missing | publication still completed, the passport remains without an IPFS copy |
| IPFS gateway unreachable | ipfs_match at null, ipfs_uri and ipfs_gateway_url missing | unknown. Try again later, or through another gateway |
| Passport published before the version chain existed | seal.linked at false, reason at sealed_before_chain | no link is manufactured after the fact |
| No attestation issued | vc.issued at false | republish the passport to trigger issuance |
| Batch modified since it was anchored | GET /v1/verify/merkle/{identifier} answers 409 | the 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.
| Code | Condition | What to do |
|---|---|---|
| 404 | no product matches the identifier | check the identifier |
| 404 | the product exists, no passport published | publish a version |
| 404 | the item belongs to no anchored batch, on /verify/merkle | normal case, do not read it as a failure |
| 404 | no attestation issued for this passport, on /vc | republish the passport to trigger issuance |
| 409 | the batch has changed since it was anchored, on /verify/merkle | the proof would be invalid on the chain, contact the brand |
| 429 | rate limit exceeded | wait 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.
Was this page helpful?
Your answer opens a pre-filled email in your mail app, addressed to contact@sealtrust.io. You read it over before sending it.