The claim
A receipt is only as good as your ability to check it. If checking a receipt meant calling a HESO endpoint and trusting the answer, the receipt would prove nothing on its own — you would just be trusting us. The goal is the opposite.
You should not have to trust HESO to believe a receipt. 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 public keys. It reaches a verdict from the receipt alone — no account, no API call, nothing of ours.
That is why the verifier is shipped as a browser package. The HESO web console verifies receipts in your browser with @hesohq/verify-wasm; you can do exactly the same thing offline, against a receipt you have never sent us.
The seven gates
Verification is an ordered list of checks, called gates. The verifier walks them top to bottom and stops at the first one that fails — it does not collect every problem, it reports the first. Each failure maps to a single verdict; passing all seven yields valid.
- Algorithm recognized. The
algstring must beheso-action/v2+ed25519. Otherwise →wrong_algorithm. - Version recognized. The
action_versionmust be a supported value (heso-action/2.0). Otherwise →unsupported_version. - Hash recomputes. The verifier recomputes the BLAKE3
action_hashover the canonical bytes and compares it to the embedded value. If they differ — a single byte of content was changed — →hash_mismatch. - Operator signature verifies. The Ed25519 signature under
key_id: "operator"must verify against the operator public key. Otherwise →invalid_signature. - Approver signature verifies. If an approver signature is present (an L1 co-signature), it must verify against the approver public key. Otherwise →
invalid_approver. - Redaction markers well-formed. Any redaction markers must be structurally valid for their mode. Otherwise →
redaction_malformed. - Trust re-derives. The verifier re-derives the trust level from which signatures actually passed and compares it to the embedded
trust_level. A receipt that claims L1 but carries only an operator signature →trust_mismatch.
Because trust is the last gate and is re-derived rather than read, a receipt can never claim more than its signatures support. See the verdicts reference for the full table of states and what triggers each.
The order matters. A receipt with both a tampered field and a bad signature reports hash_mismatch, because the hash gate comes first. The verdict always names the earliest defect, so two verifiers given the same receipt agree on the same single answer.
Byte for byte
Step three — recomputing the hash — only works if everyone agrees on which bytes to hash. HESO fixes those bytes with canonicalization: one fixed way to write the content as JSON. The content is serialized with RFC-8785 (JCS, the JSON Canonicalization Scheme), which sorts keys and normalizes the output so the same content always produces the same bytes. The action_hash field is stripped before hashing — you cannot hash a value into itself.
The key rule: the browser must call the shared Rust canonicalizer, never its own JCS code. Canonicalization, BLAKE3, and Ed25519 are not re-implemented in JavaScript or Python — every surface calls the one Rust core. That is what makes a verdict in the browser byte-identical to one on the server: the same bytes go into the same hash function and the same signature check, everywhere.
When the cloud accepts a receipt at POST /v1/receipts, it re-verifies through this same core before storing it. The server is not a more-trusted verifier; it runs the identical gates you can run locally.
Your own JCS code that orders keys or formats numbers even slightly differently will produce different bytes, a different BLAKE3 hash, and a false hash_mismatch on a receipt that is actually valid. Always route through the core (@hesohq/core, @hesohq/verify-wasm, or the Python heso package). Never rebuild the canonical bytes by hand.
Where it runs
The same verifier ships on every runtime you are likely to need. The call is the same — verifyActionReceipt — and the verdict is the same, because the same Rust core runs underneath.
| Runtime | Package | Notes |
|---|---|---|
| Browser | @hesohq/verify-wasm | Verify-only WASM, ESM. The default export is the async init. The HESO console itself uses this. |
| Node | @hesohq/core | The native addon: verify, sign, redact, and keys — same core, in-process. |
| Python | heso | Bundles the Rust core as the heso._core wheel; verification is in-process. |
import init, { verifyActionReceipt } from "@hesohq/verify-wasm" // the default export is the async init — run it onceawait init() const result = verifyActionReceipt(receipt)result.state // "valid" | "hash_mismatch" | "invalid_signature" | …
See the SDK reference for each surface: @hesohq/verify-wasm, @hesohq/core, and heso. For a complete walkthrough of verifying a receipt in a page with no backend, read Quickstart: Verify in the browser.
What a pass means
A Valid verdict is a precise statement, and only that statement. It means two things, both re-derived from the artifact:
- The content matches its signature — the bytes you are reading are exactly the bytes the operator signed, unaltered since.
- The re-derived trust level matches the claimed one — so the operator authorized this action under a known policy, and at L1 an authorized human approved it with their own device-held key.
What Valid covers
Valid means the action was authorized: the operator authorized this exact action under a known policy, and the bytes are untampered. It records what was authorized, not the downstream outcome. A signed, hash-clean receipt for a tool call proves the operator authorized that call — separate from whether the tool returned correct data or a payment settled.
Verification 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 happened in the world is a separate question.
