Offline verification
Verification re-runs a receipt’s own cryptography to confirm it has not been altered, and it needs nothing from HESO — no network, no account, no API key.
How to verify
Initialize the WASM module once with await init(), then pass the raw receipt bytes to the verifier. In the browser (and Node) the call is verifyActionReceipt(bytes) — where bytes is the receipt JSON encoded with a TextEncoder — which returns { verdict, trust_level }. A verdict of Valid means the bytes match their signature; any other value names the first gate that failed. trust_level is re-derived from the signatures present, not read off the wire.
import init, { verifyActionReceipt } from "@hesohq/verify-wasm"
await init() // fetch the .wasm once; cache the promise
// The verifier takes the raw receipt bytes, not the object.
const bytes = new TextEncoder().encode(JSON.stringify(receipt))
const { verdict, trust_level } = verifyActionReceipt(bytes)
if (verdict === "Valid") {
// the bytes match the signature; trust_level is re-derived ("L0" | "L1")
} else {
// verdict names the first gate that failed, e.g. "HashMismatch"
}Two more surfaces run the same check with zero code: the heso verify CLI command verifies a saved receipt or one looked up by hash, and the hosted /verifypage checks a pasted or uploaded receipt in your browser with no login. For the full walkthrough — serving the .wasm, handling each result — read Quickstart: verify in the browser.
The gate order
The verifier walks a fixed sequence of gates top to bottom and stops at the firstfailure. It does not collect every problem — it reports the earliest defect, so two verifiers given the same receipt return the same single answer. Each failure maps to one status; passing every gate yields Valid.
- Parse and structure.The receipt JSON must parse into the expected shape. A truncated or structurally broken receipt →
Malformed. - Algorithm and version. The
algmust beheso-action/v2+ed25519and theaction_versionmust be a supported value, elseWrongAlgorithm. - Content hash recompute. The verifier recomputes the BLAKE3
action_hashover the canonical bytes and compares it to the embedded value. One changed byte of content →HashMismatch. - Ed25519 signatures.The operator signature must verify against the operator public key, and any approver co-signature must verify against the approver key. A bad signature →
InvalidSignature(e.g.InvalidSignature:operator). - Trust-level re-derivation. The verifier derives the trust level from which signatures actually passed and checks it against the claimed
trust_level. A receipt that claimsL1with only an operator signature →TrustLevelMismatch. - Chain link. If the receipt carries a
prev_receipt_hash, the link to the prior receipt must hold; a broken link →InvalidSignature. - Time anchor (optional). A receipt with an RFC-3161
time_anchorhas it verified fail-closed; if the policy required an anchor and none is present, that fails too. Without an anchor this gate is skipped.
| Gate | Rejects with |
|---|---|
| Parse / structure | Malformed |
| Algorithm / version | WrongAlgorithm |
| Content hash | HashMismatch |
| Signatures, chain, anchor | InvalidSignature |
| Trust level | TrustLevelMismatch |
| All gates pass | Valid |
The verdicts reference lists every status and the exact condition that triggers it.
A receipt with both a tampered field and a bad signature reports HashMismatch, because the hash gate runs before the signature gate. The status always names the earliest defect.
Why offline matters
Everything needed to check a receipt lives inside the receipt: the canonical content, the public keys, the signatures, and the claimed trust level. The verifier recomputes the hash and re-checks every signature against those keys, then reaches a verdict from the artifact alone. No account, no API call, nothing of ours.
That verdict is the same wherever you run it. Canonicalization (RFC-8785 / JCS), BLAKE3, and Ed25519 are not re-implemented in JavaScript or Python — one Rust core compiles to a Python wheel, a Node addon, and a browser WASM module, so the same bytes flow into the same hash and the same signature check on every surface. The cloud is not a more-trusted verifier; when it accepts a receipt at POST /v1/receipts it re-runs these identical gates. You do not have to trust HESO to believe a receipt.
JCS code that orders keys or formats numbers even slightly differently produces different bytes, a different BLAKE3 hash, and a false HashMismatch on a receipt that is actually valid. Always route through the core (@hesohq/verify-wasm, @hesohq/core, or the Python heso package). Never rebuild the canonical bytes by hand.
Trust is re-derived
trust_level in the result is not copied from the receipt. The verifier derives it from the signatures that actually passed: an operator signature alone yields L0, an operator plus a human approver co-signature yields L1. A receipt cannot claim more than its signatures support. See trust levels for what each level proves.
A Validresult proves authorization and integrity: the right parties signed these exact bytes under a known policy. It records what was authorized, not the downstream outcome — whether the action actually succeeded in the world is a separate question.