Start free

Human approval

When policy routes an action to a person, that person clears the gate by signing an approval token with their own device-held key. The result is an L1 receipt — and the cloud never holds a signing key.

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 approvers and may set an sla_minutes deadline.
  • 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:

bash
# 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:

bash
# 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 bytes are not on the wire

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.

The cloud holds no approver signing key

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.

  1. POST /v1/approvals opens the approval and returns an approvalId, an optional token, and an expiresAt.
  2. POST /v1/approvals/{approvalId}/token submits the signed token and returns the Approval, now carrying the signed ApproverRecord.
# 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.

verify-token.ts
import { verifyApprovalToken } from "@hesohq/core" // Re-derive the verdict from the bytes — the same Rust core the cloud uses.const ok = verifyApprovalToken(token, actionCanonicalBytes)
Re-verify after approval

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.

Next steps