Most actions are decided by policy alone. Some are not. A payment over a cap, a bulk delete, an account change — these are the lanes where you want a named person to look before the action runs. HESO calls that human approval: a policy decision routes the action to one or more people, and the action stays paused until one of them signs off. The signature is real cryptography, not a checkbox.
When approval happens
A policy rule with the decision require_approval routes the matched action to a person instead of allowing or blocking it outright. This is one of the four decision paths — allow, block, redact, and route-to-human. When a rule routes to a human:
- The action does not run. The SDK suspends the call at the gate and opens an approval.
- The rule names one or more
approversand may set ansla_minutesdeadline. - The action resumes only after an approver approves it. A rejection stops it; an escalation hands it on.
This is also the default for dangerous lanes that no rule matched. HESO is deny-unknown: an unmatched payment, delete, account change, or large data export falls through to require_approval rather than running unattended. See pinned floors for the lanes a policy can tighten but never bypass.
The approval token
An approver clears the gate by producing an approval token— an Ed25519 signature over the exact action being approved. Binding the signature to the action’s canonical bytes is what makes the approval non-transferable: it cannot be lifted off one action and reused on another, because the bytes would no longer match.
The signed payload is a fixed, ordered concatenation of these parts:
# The bytes the approver signs (the signed payload): domain separator "heso-approval-token/v1" + NUL bytenonce 32 bytesexpiry 8 bytes, big-endian unix secondsscope length 4 bytes, big-endianscope <scope length> bytesaction the action canonical bytes (RFC-8785 / JCS)
The domain separator (heso-approval-token/v1, NUL-terminated) keeps these signatures from ever colliding with any other Ed25519 signature HESO produces. The action bytes are the same RFC-8785 (JCS) canonical bytes the operator signed, so the approver and the operator are demonstrably co-signing one identical action.
The wire token the approver returns carries everything a verifier needs to check it, with no extra lookup:
# The wire token the approver returns (the bytes on the wire): nonce 32 bytesexpiry 8 bytes, big-endianscope length 4 bytes, big-endianscope <scope length> bytessignature 64 bytes (Ed25519 over the signed payload above)public_key 32 bytes (the approver's Ed25519 public key)
The action canonical bytes are part of what gets signed, but they are not repeated inside the wire token — the verifier already has them from the receipt. Verification recombines the wire token’s nonce, expiry, and scope with the action bytes, rebuilds the signed payload, and checks the 64-byte signature against the 32-byte public key.
Device-held keys
Every approver holds a distinct Ed25519 keypair. The private key lives on the approver’s device and never leaves it. Only the public fingerprint is ever shared — a short identifier like ed25519:a14f0c2db8e6 that names the key without exposing it. The signing interface is WebAuthn-ready, so the key can be held by a platform authenticator or a hardware security key.
Because the private key is on the device, the approval signature is something only that person could have produced. The cloud control plane routes the request and stores the resulting record, but it has nothing to sign with — it cannot forge an approval even in principle.
At L1, the approver signs with their own device-held key. The HESO cloud never holds an approver private key, so it cannot mint an L1 co-signature. A receipt’s trust level is re-derived on every verify from which signatures actually pass — a receipt that claims L1 without a valid approver signature fails with trust_mismatch.
Routing and SLA
Where an approval goes is set on the rule that triggered it. The approvers field is an array of label strings — the roles or people a routed action can go to. The optional sla_minutes field sets a deadline for a decision.
- approversstring[]
- One or more approver labels the routed action can be sent to. The first to sign clears the gate.
- sla_minutesnumber
- Optional deadline for a decision. When the SLA lapses, the action can escalate or fall back rather than sit silently.
When no one decides inside the SLA, the approval can escalate to a fallback approver rather than stall indefinitely — and an approver can escalate by hand, recording an escalated decision on the action. See policy and decisions for how approvers and sla_minutes are written on a rule.
The approver record
A cleared approval attaches an ApproverRecord to the receipt. This is the durable evidence of who decided, what they decided, and why. When the decision is approved and the approver signature verifies, the record lifts the receipt from L0 to L1.
- decision"approved" | "rejected" | "escalated"required
- The approver's verdict. Only approved (with a passing signature) lifts the receipt to L1; rejected stops the action; escalated hands it on.
- approver_identitystringrequired
- The approver's Ed25519 public key, base64-encoded — the same key the token signature is checked against.
- reasonstringrequired
- The approver's free-text rationale, recorded with the decision.
- decided_atstringrequired
- ISO-8601 timestamp of when the decision was made.
- sla_minutesnumber
- The deadline that applied to this decision, carried through from the rule when one was set.
The record sits in the receipt’s content.approver_decision, and the matching Ed25519 signature lands in signatures under key_id: "approver". For the full envelope, see action receipts.
Submitting a decision
Approvals flow through the cloud API in two steps: open an approval for the captured receipt, then submit the signed token to clear the gate.
POST /v1/approvalsopens the approval and returns anapprovalId, an optionaltoken, and anexpiresAt.POST /v1/approvals/{approvalId}/tokensubmits the signed token and returns theApproval, now carrying the signedApproverRecord.
# 1) Open an approval for a captured receipt.curl -X POST "$HESO_API/v1/approvals" \ -H "Authorization: Bearer $HESO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "receipt": { … }, "routingHint": "payments-oncall" }' # -> { "approvalId": "apr_8f3…", "token": "…", "expiresAt": "2026-06-06T18:30:00Z" } # 2) The approver signs, then submits the wire token to clear the gate.curl -X POST "$HESO_API/v1/approvals/apr_8f3…/token" \ -H "Authorization: Bearer $HESO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "token": "…" }' # -> the Approval, now carrying the signed ApproverRecord
From TypeScript, submitApprovalToken sends the signed token and waitForApproval polls until someone decides. See the TypeScript SDK for the full client and the wire types.
To check a token without the cloud, re-derive the verdict from the bytes with verifyApprovalToken — available in both Node (@hesohq/core) and the browser. Like every HESO verdict, it runs against the one Rust core, so the answer is byte-identical on your server and in a reviewer’s browser.
import { verifyApprovalToken } from "@hesohq/core" // Re-derive the verdict from the bytes — the same Rust core the cloud uses.const ok = verifyApprovalToken(token, actionCanonicalBytes)
The cloud re-verifies a receipt before accepting it, and so should you. An approved receipt is only L1 if its approver signature passes — re-run offline verification before you act on the result.
