Proof bundles
The file a person downloads about one record: what is in it, the chain of checks it supports, and the legitimate reasons the record itself is absent.
What a bundle is
A bundle is a self-contained JSON file about one record — a ledger entry, an audit event or a public transparency fact. A person gets it from their own account and can hand it to anybody: a regulator, a lawyer, you. Nothing in it needs UnifyID to be online to check.
`format: "unifyid.proof-bundle"`, `version: 1`
generatedAt- When the file was produced.
subjectkind— one ofledger_entry,audit_event,transparency_fact— plusid,eventId,stream,occurredAtand a plain-languagedescriptionfor whoever opens the file.staterecorded,sealed,anchored_one_channeloranchored_both. Today the reachable values are the first two; the other two become reachable when anchoring starts at launch.recordWithheld- A sentence saying why the record itself is not in the file, or
nullwhen it is present. See below. proof- The inclusion proof, or
nullwhile the record is not yet sealed. anchors{ unifyid_contract, sign_protocol }, each an anchor reference ornull.keysUrl- Where UnifyID publishes its checkpoint keys. Fetch them yourself, or use a copy you already trust.
howToVerify- A line of instructions. It names the integrity verifier — read the note at the end of this page before following it.
A set of bundles in one file is format: "unifyid.proof-bundle-set", version: 1, with generatedAt and a bundles array — used when a person exports everything about one recipient on their data-sharing ledger.
The salted leaf
The inclusion proof's leaf object is where a record becomes a hash. It carries state, leafSalt, contentHash and leafHash.
leafHash = SHA-256( 0x00 || leafSalt || contentHash ) // 65 input bytes
0x00 one byte, the RFC 6962 leaf prefix
leafSalt 32 random bytes, assigned by the database when the event was stored
contentHash 32 bytes: SHA-256 of the canonical JSON of the hashed envelopeThe salt is not decoration. Without it, anybody who could guess a record's exact contents could hash it and test that guess against a published root or against a proof somebody else shared. With it, confirming a guess requires the salt, and the salt only travels inside a proof the record's owner chose to hand over.
Canonical JSON, for check one below: object keys sorted by code unit, no whitespace, numbers and strings exactly as JSON writes them. Legacy rows at schema version 0 have no envelope and skip that check.
The chain of checks
Ten checks lead from a record to a chain. Each one only means something because the one before it held.
Checks 9 and 10 cannot pass today: nothing of UnifyID's log is anchored until anchoring starts on Base mainnet at launch.
| # | Check | What it establishes |
|---|---|---|
| 1 | record | The record in the file — or one you supply yourself — hashes to the sealed contentHash. A single changed field fails here. |
| 2 | leaf | The salted leaf is SHA-256(0x00 || leafSalt || contentHash). |
| 3 | inclusion | The RFC 6962 inclusion proof leads from that leaf to the stream's Merkle root. |
| 4 | stream_head | That root is the head this checkpoint commits to for that stream. |
| 5 | combined_root | The five stream heads combine to the checkpoint's combinedRoot. |
| 6 | statement | The 256-byte statement decodes, matches the view, and hashes to the checkpoint fingerprint. |
| 7 | signing_key | The statement names one of UnifyID's published keys. |
| 8 | signature | That key signed the statement — ES256, low-S. |
| 9 | unifyid_contract | UnifyID's contract on Base holds this fingerprint at this sequence, with enough confirmations. Undecided until anchoring starts at launch. |
| 10 | sign_protocol | Sign Protocol's attestation, found through UnifyID's hook, carries the identical statement and signature. Undecided until then, for the same reason. |
Checks 1 to 8 are the ones you can run today, and steps 6 to 8 are exactly what Verify a checkpoint yourself walks through. Steps 1 to 5 need the proof file, which needs the record's owner.
The inclusion proof
{
"format": "unifyid.audit.inclusion-proof",
"version": 1,
"stream": "disclosure",
"eventId": "uuid",
"sequence": "42",
"leafIndex": "41",
"leaf": {
"state": "present",
"leafSalt": "hex64",
"contentHash": "hex64",
"leafHash": "hex64"
},
"treeSize": "120",
"rootHash": "hex64",
"auditPath": ["hex64"],
"streamHeads": [{ "stream": "audit", "treeSize": "10", "rootHash": "hex64" }],
"checkpoint": { "…": "the checkpoint view" },
"envelope": { "canonicalization": "unifyid-canonical-json-v1", "schemaVersion": 2, "value": {} }
}leafIndex is sequence - 1; stream sequences start at 1 and have no gaps. auditPath is the RFC 6962 path, bottom-up, the leaf's sibling first — verify it with RFC 9162 section 2.1.3.2. streamHeads always lists all five streams in the combined-root order, which is what lets the holder recompute combinedRoot without being told the per-stream roots publicly.
envelope is null unless it was asked for. When present it is the exact value the content hash was computed over — which is what makes check one possible without trusting the file's own summary of the record.
A consistency proof, format: "unifyid.audit.consistency-proof", does the other job: given two checkpoints it shows that every stream only grew between them, with an RFC 6962 consistency proof per stream. It is empty when the two tree sizes are equal, and there is none from an empty tree.
A record can legitimately be absent
Two different absences, with two different meanings. Neither is a failure, and confusing them with tampering is the mistake this section exists to prevent.
recordWithheldthe content was not included- The proof is there; the record's contents are not. The default sentence is "The record itself is not in this file. The proof still shows it was sealed and has not changed." You can still run checks 2 to 8. Check one waits for a copy of the record, which you can supply yourself and compare.
leaf.state: "tombstone"the record was purged- Retention deleted the event.
leafSaltandcontentHasharenulland only the leaf hash survives. The leaf, its neighbours' proofs and the chain still verify — there is simply no content left to hash. A tombstone keeps(stream, sequence, leafIndex, eventPublicId, leafHash, linkHash, checkpointId)for the life of the log.
A tombstone is the log keeping its promise, not breaking it: the retention rule deleted the data and the log still cannot lose or reorder the slot it occupied. A verifier that treated a tombstone as tampering would be punishing the platform for honouring a deletion.
And proof: null with state: "recorded" is a third, more ordinary case: the record is in the log but the next seal has not run. Ask again after the next checkpoint.
Refusals
PROOF_RECORD_NOT_FOUND404- "There is no sealed record with that id."
AUDIT_EVENT_NOT_FOUND404- No such event.
AUDIT_PROOF_NOT_SEALED409- Recorded, but not yet in a checkpoint — or not in the checkpoint you pinned.
AUDIT_CHECKPOINT_NOT_FOUND404- No checkpoint with that sequence.
AUDIT_PROOF_RANGE_INVALID400- A sequence or range that is not a sequence or range.
AUDIT_PROOF_INCONSISTENT500- The stored tree did not produce a valid proof. This is treated as an integrity failure on our side, not a bad request on yours.
npm install --global @vn.unifyid/integrity-verifier
# or pin the release you reviewed: @vn.unifyid/[email protected]
unifyid-integrity-verify proof unifyid-proof.json --keys keys.json--keys takes the key set you archived rather than fetching one, for the reason Verify a checkpoint yourself gives. The on-chain checks need --rpc-url, a Base endpoint you choose rather than one of ours (for example https://mainnet.base.org, chain id 8453). Until anchoring starts at launch they are undecided — exit 3.
What this does not do
- You do not fetch a bundle. It belongs to the person whose record it is, behind their access token, with
Cache-Control: private, no-store. If you need one, they hand it to you. - Checks 9 and 10 do not pass yet. Every published description of the verification chain lists ten steps; eight of them are reachable today, and the last two from launch. A report that stops at check 8 is complete, not truncated.
- A proof proves sealing, not truth. It shows a record is the one UnifyID sealed and has not changed since. Whether what the record says is accurate is a different question, and no hash answers it.
- Per-stream sizes leave with the proof.
streamHeadstells its holder how many events each stream held at that checkpoint. That is not public, and handing a proof to somebody hands them that too.
Verified against API commit 234dba22 on 2026-10-18. The routes, scopes, checklist, events and limits on these pages are read from that export, not typed.