Every HESO action produces an Action Receipt: a signed JSON object that records one action and the policy verdict that gated it. Verifying it means recomputing its hash and checking its signatures — pure math, no server required. @hesohq/verify-wasm is the verify-only WebAssembly build of the same Rust core that signs receipts, so the verdict you get in the browser is byte-identical to the one a server would give. The HESO web console uses this exact package to verify receipts.
This package can only check receipts; it cannot sign them. There is no signing code and no private key in the browser. That is the point: a verifier should never need a secret to confirm what a signature already proves.
Install
Add the package. It ships as ESM only — import it from module code, not a CommonJS require.
pnpm add @hesohq/verify-wasmThe package ships a .wasm file that must be served, since init() fetches it at runtime. If your bundler does not emit it automatically, see the Next.js guide for App Router setup.
Initialize
The default export is an async init (this is a wasm-bindgen --target web build). Await it once before calling anything else; after that, the named exports are synchronous. Cache the promise so the .wasm is fetched only once.
import init, { verifyActionReceipt } from "@hesohq/verify-wasm" // Call once, then cache the promise. init() fetches and instantiates the .wasm.let ready: Promise<unknown> | null = nullfunction loadVerifier() { if (!ready) ready = init() return ready}
init() resolves to an InitOutput. You rarely need it — once the promise resolves, just call verifyActionReceipt and the other named exports directly. A synchronous initSync(module) exists too, if you already hold the compiled module.
Verify a receipt
verifyActionReceipt takes the receipt as raw bytes. Encode the receipt JSON to a Uint8Array, pass it in, and read the result off the returned ActionVerdict.
import init, { verifyActionReceipt } from "@hesohq/verify-wasm" await init() // once, up front const bytes = new TextEncoder().encode(JSON.stringify(receipt))const v = verifyActionReceipt(bytes) console.log(v.verdict) // "Valid" — or a variant name on failureconsole.log(v.trust_level) // "L0" | "L1"
- receiptBytesUint8Arrayrequired
- The receipt JSON, UTF-8 encoded. The verifier canonicalizes the bytes itself, so you can pass the receipt exactly as you received it.
The return value is an ActionVerdict with two fields: verdict (a string) and trust_level (a string).
Read the verdict
v.verdict === "Valid" means the receipt passed every gate — the algorithm and version are recognized, the recomputed BLAKE3 hash matches, the Ed25519 signatures verify, any redaction markers are well-formed, and the embedded trust level matches what the signatures actually support. Anything else is a variant name that pins the first failing gate.
const v = verifyActionReceipt(bytes) if (v.verdict === "Valid") { // Passed every gate. trust_level is now meaningful. if (v.trust_level === "L1") { // operator + human approver co-signature }} else { // A variant name pins the first failing gate, e.g. // "HashMismatch" // "InvalidSignature:…" // "TrustLevelMismatch:embedded=L1,derived=L0" console.warn("receipt rejected:", v.verdict)}
v.verdict—"Valid"on success, otherwise a variant name such as"HashMismatch","InvalidSignature:…", or"TrustLevelMismatch:embedded=L1,derived=L0".v.trust_level—"L0"(operator-signed) or"L1"(operator plus a human approver co-signature). It is only meaningful when the verdict is"Valid".
The trust level is re-derived from which signatures actually pass — never trusted as a claim. A receipt that embeds a higher level than its signatures support fails with TrustLevelMismatch. For the full gate order see Offline verification, and for every verdict name and its meaning see the Verdicts reference.
Check a chain
A single receipt proves one action. A run is a sequence of receipts linked by BLAKE3 into a tamper-evident chain: altering any earlier receipt breaks every downstream link. verifyChain verifies the whole chain — pass a JSON array of receipts, in sequence order, as bytes.
import init, { verifyChain } from "@hesohq/verify-wasm" await init() // receipts is a JSON array of ActionReceipt objects, in sequence order.const bytes = new TextEncoder().encode(JSON.stringify(receipts))const result = verifyChain(bytes) if (result.ok) { console.log("chain intact,", result.length, "entries")} else { // The link broke at result.seq; result.detail explains how. console.warn("chain broken at", result.seq, "—", result.error)}
It returns a ChainResult describing whether the chain is intact, and where it broke if it is not.
- okboolean
- True when every link in the chain verifies.
- lengthnumber?
- The number of entries verified, present on success.
- seqnumber?
- The sequence number of the entry where the chain broke.
- errorstring?
- A short reason the chain failed.
- detailstring?
- Extra context explaining how the link broke.
For how the chain is built — the genesis link and how each chainHash binds an entry to the one before it — read The audit chain.
Why offline matters
The whole check runs on the user’s device. No bytes leave the page, no API key is needed, and no HESO service is contacted — so you do not have to trust anyone, including HESO, to believe the result. A receipt either re-hashes and re-verifies under the embedded public keys, or it does not. The math is the authority, not a vendor.
That is what makes a receipt portable evidence. You can hand one to an auditor, a counterparty, or a regulator, and they can confirm it with this package and nothing else. We host a public verifier at /verify that does this in the browser, but the package is the real artifact — anyone can run it anywhere.
A Valid verdict proves the operator authorized this action under a known policy, and at L1 that a person approved it with a device-held key. It records what was authorized, not whether the action succeeded downstream.
