# 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.

Source: https://docs.sealtrust.io/en/confiance-et-preuves/

---

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 |

> [!ATTENTION] Two anchorings, two subjects
> The `anchor` block dates **the item**. The `passport_anchor` block dates **the
> document**. No record made for the item covers the passport's content. A page
> that announces "passport anchored on Base" on the strength of the `anchor`
> block claims more than what the chain says.

The two mechanisms have the same shape and never cross.

:::schema deux-ancrages
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`.

:::onglets
```bash title="curl"
curl "https://api.sealtrust.io/v1/passport/000000000000/proof"
```
```typescript
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);
```
```python
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"))
```
:::

```json title="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.

> [!INFO] Why the link is sometimes missing
> The IPFS link is removed in two cases: the retrieved copy does not match the
> expected public projection, or no gateway answered. We serve a link only when
> we have been able to verify its content. An unreachable gateway is therefore
> enough to make the link disappear from one response to the next. Write down
> the content identifier the day you obtain it.

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`.

```bash title="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.

:::onglets
```python
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",
        ],
    )
)
```
```typescript
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.

:::onglets
```python
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)
```
```typescript
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.

> [!ATTENTION] What you cannot recompute on your own
> `data_hash` covers the entirety of the version's data, including the fields
> your access tier does not receive. Without that complete data, you verify that
> the published hash is indeed the one that was anchored, and you cannot link
> that hash to the text you are reading. The IPFS copy covers that scope: it is
> the public projection, and it is frozen.

## 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.

```bash title="curl"
curl "https://api.sealtrust.io/v1/verify/merkle/000000000000"
```

```json title="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 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.

> [!INFO] A 404 here is the normal case
> Most items do not belong to an anchored batch. This endpoint then answers 404.
> Treat that response as "this item is not part of an anchored batch", never as
> a verification failure.

## 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.

```bash title="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.

> [!INFO] Where the private key lives
> The private signing key is never returned by the API. We publish only the
> public part, the one carried by the issuer's document, and that is the one you
> use to verify.

## 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.
