Let your customers approve
When policy routes an action to a human, that human can be your customer. They review and co-sign it from an approval gate embedded in your own product, with a key held on their device. A receipt proves you authorized an action under your policy; it does not prove the action succeeded downstream.
HESO’s usual human approval sends a held action to your on-call reviewer. A client-approval gate sends it instead to the person the action is about: the customer whose money is moving, whose data is being exported, whose account is changing. They approve inside your product, and their approval is a real Ed25519 co-signature, not a checkbox.
The trust model behind this — you vouch, the customer co-signs, HESO verifies, and no customer base is stored with us — lives in the client-approval trust model. Read it once; this page is the build steps.
The flow
Four moving parts, all keyed to one action:
- Policy routes the action. A rule matches and its decision is
require_approval. The SDK suspends the action and gives you itsaction_hash. - You present the gate. Your server opens an approval for that hash, mints a one-time gate token, and embeds the gate (link or iframe) in front of your customer.
- The customer co-signs.They review the exact action and approve. Their browser mints a one-time key and co-signs the action’s digest with it. The key never leaves the device.
- The token lifts the receipt. The signed token is submitted; HESO verifies the operator delegation and the customer co-signature, the approval flips to
approved, and the receipt becomes L1.
Open a gate
Once policy hands you an action_hash, opening a gate is two server calls. First open the approval; then mint a client token — a single-use bearer good for about five minutes, plus the iframe URL. Both run on your server with your API key.
// ON YOUR SERVER — never the browser. SDK plane: authenticate with your
// API key in the x-api-key header.
// 1) Open an approval for the action policy routed to require_approval.
const open = await fetch(`${HESO_API}/v1/approvals`, {
method: "POST",
headers: { "x-api-key": HESO_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
action_hash: actionHash, // 64-hex BLAKE3 digest of the captured action
rule_id: ruleId, // the rule that gated it (becomes the gate's scope)
is_floor: true, // mark a dangerous lane (payment/delete/...) -> heso-rendered gate
}),
}).then((r) => r.json())
// open -> { approval_id, action_hash, status: "pending", threshold, approved_count }
// 2) Mint a single-use bearer (~300s) + the iframe URL to embed.
const client = await fetch(
`${HESO_API}/v1/approvals/${open.action_hash}/client-token`,
{ method: "POST", headers: { "x-api-key": HESO_API_KEY } },
).then((r) => r.json())
// client -> { bearer: "…", iframe_url: "https://gate.heso.ca/gate/…" }- POST /v1/approvals→ { approval_id, action_hash, status }
- Opens (or idempotently re-opens) the approval. Body requires action_hash; rule_id sets the gate's scope, is_floor marks a dangerous lane.
- POST /v1/approvals/{action_hash}/client-token→ { bearer, iframe_url }
- Mints a single-use, ~300s bearer and the gate iframe URL. Mint a fresh one per gate; it is consumed when the iframe loads.
Embed the gate
Drop one container, load embed.js, and call heso.mountGate. The script mounts the heso-rendered iframe; the bearer and the signed envelope are handed to it over postMessage after it signals ready, so they never ride the URL or the referrer.
<div id="approval"></div>
<script src="https://gate.heso.ca/embed.js"></script>
<script>
const gate = heso.mountGate({
el: document.getElementById('approval'),
bearer, // from POST /v1/approvals/{action_hash}/client-token
receipt, // the action content your customer is reviewing
scope, // the rule the gate clears (must match the envelope)
// The iframe mints the customer key K and publishes it; you sign a delegation
// envelope for THAT exact K on your server and return its base64.
mintEnvelope: async (publicKeyB64) => {
const r = await fetch('/api/heso/envelope', {
method: 'POST',
body: JSON.stringify({ actionHash, publicKeyB64 }),
})
return (await r.json()).envelopeB64
},
onResolved: (outcome) => {
// 'approved' | 'rejected' — re-verify the receipt before you act on it
},
})
</script>The delegation envelope must name the customer’s key K, and K is minted insidethe iframe at approve-time and never leaves the customer’s browser. So mountGate runs a two-step handshake: the iframe publishes its public K, your mintEnvelope(publicKeyB64) callback signs an envelope for that exact key on your server, and the gate proceeds. An envelope minted for any other key fails verification.
Sign the delegation
The envelope is what lets the customer’s one-time key co-sign on your authority. Mint it server-side from your operator private key, inside the mintEnvelope callback above. @hesohq/sdk’s mintDelegationEnvelope assembles the wire bytes; your signer signs them.
// ON YOUR SERVER — never the browser. Runs inside the mintEnvelope callback,
// once the iframe has published the customer's key K.
import { mintDelegationEnvelope } from "@hesohq/sdk"
// The signer wraps your operator PRIVATE key. The SDK assembles the wire bytes
// (byte-exact with the engine's verify_delegation); your signer signs them.
const priv = await crypto.subtle.importKey(
"pkcs8", operatorPrivateKeyDer, { name: "Ed25519" }, false, ["sign"],
)
const signer = {
publicKeyRaw: operatorPublicKeyRaw, // raw 32 bytes — the half you registered with HESO
sign: async (msg) => new Uint8Array(await crypto.subtle.sign("Ed25519", priv, msg)),
}
const { envelopeB64 } = await mintDelegationEnvelope({
actionHash, // raw 32 bytes — the gate this envelope authorizes
authorizedKey: K, // the customer's public key, from the iframe (raw 32 bytes)
scope: ruleId, // must match the rule the gate clears
signer, // holds your operator PRIVATE key (server-only)
})- actionHashstring | Uint8Arrayrequired
- The 32-byte action digest this gate authorizes (hex or raw).
- authorizedKeyUint8Arrayrequired
- The customer's public key K, raw 32 bytes, published by the gate iframe.
- scopestringrequired
- The rule id the gate clears. Must match the gate's scope and the customer's co-sign.
- signer{ publicKeyRaw, sign }required
- Holds your operator PRIVATE key: a raw 32-byte public half plus a sign(msg). Server-only — never construct this in the browser.
- ttlSecsnumber= 300
- How long the gate stays valid, in seconds.
What the customer sees
Inside the embed the customer sees the exact receipt content they’re approving and an Approve / Decline choice. When they approve:
- The iframe mints a one-time key
Kon the device and co-signs the action’s raw digest with it. - The bearer is consumed via
POST /v1/approvals/gate/resolve, which stamps that the gate was genuinely heso-rendered. - The signed token is submitted to
POST /v1/approvals/{action_hash}/submit-tokenwith the delegation envelope and the bearer. The backend re-derives the floor from your operator-signed verb and runsverify_delegation; if both pass, the approval flips and the receipt lifts to L1.
A receipt is L1 only if the delegation and the customer co-signature both verify, and an L1 receipt proves you authorized the action under your policy — not that the downstream action succeeded. The cloud re-verifies before it accepts the token, and so should you: re-run offline verification before the action runs.
Set up once
Before any of this works, register your operator key. Generate an Ed25519 keypair on your own server; the private half stays there and signs every delegation, and you register only the public half. HESO holds no signing key and never signs. Do this in Settings → Client approvals, or call the API.
# Register your operator PUBLIC key + the origins your embed runs on.
# Console (session) action — owner / security_admin — so it carries your dashboard
# session JWT, not an API key. The dashboard is the easy path; raw call shown here.
curl -X POST "$HESO_API/v1/orgs/register-operator-pubkey" \
-H "Authorization: Bearer $HESO_SESSION" \
-H "Content-Type: application/json" \
-d '{
"key_id": "operator-1",
"public_key": "<base64 Ed25519 public key>",
"label": "prod-gate",
"allowed_origins": ["https://app.acme.com"]
}'
# -> { "status": "registered", "key_id": "operator-1", "public_key": "…" }- public_keystringrequired
- Base64 of your operator key's PUBLIC half. The private half never leaves your server.
- allowed_originsstring[]
- Browser origins the gate iframe may be embedded from (e.g. https://app.acme.com). Empty = no origin allow-list.
- key_idstring
- The stable binding id. Re-registering the same key_id with a new public_key is a rotation — append-only; the old binding is retired, never overwritten.
- labelstring
- A human label for the key, shown in the dashboard's custody history.
You also mark which policy rulesa customer may clear. A gate can only clear a rule you’ve opted in; everything else stays on your team’s approval path. The co-signature is bound to that rule’s scope, and a pinned floor still cannot be cleared past — the backend re-derives the floor from the operator-signed verb on submit.