HESO’s usual human approval routes a held action to your people — an on-call reviewer with a device-held key. A client-approval gate routes 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, in a small embedded panel, and their approval is the same real Ed25519 co-signature — not a checkbox.
When to reach for this
This is the right tool when the human who should look is your end user, not your staff. For example:
- A payout or transfer that the account holder should confirm before it leaves.
- A data export or deletion that the data subject should authorize.
- An agent acting on a customer’s behalf where you want the customer’s explicit, signed go-ahead on the record.
If the reviewer is one of your people, use human approval instead — same crypto, your team holds the keys. The trust model behind client approvals is spelled out in the client-approval trust model: you vouch, the customer co-signs, HESO verifies, and no customer base is ever stored with us.
Turn it on (once)
Setup happens one time, in the dashboard. You register your operator public key, tell HESO which pages the gate may be embedded from, and mark which policy rules a customer is allowed to clear.
Register your operator key
Generate an Ed25519 keypair on your own server. The private half stays there and signs every delegation; you register only the public half with HESO, which uses it to verify. Do this in Settings → Client approvals, or call the API directly.
# Register your operator PUBLIC key + the origins your embed runs on.# This is a console (session) action — owner / security_admin only — so it carries# your dashboard session JWT, NOT an API key. The easiest path is the dashboard;# the raw call is here for completeness.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": "…" }
The same call sets allowedOrigins — the browser origins your gate is allowed to be embedded from. Leave it empty and no origin allow-list is applied; set it to lock the embed to your own app.
- publicKeystringrequired
- Base64 of your operator key's PUBLIC half. The private half never leaves your server and is never sent to HESO.
- allowedOriginsstring[]
- Browser origins the gate iframe may be embedded from (e.g. https://app.acme.com). Empty = no origin allow-list.
- labelstring
- A human label for the key, shown in the dashboard's custody history.
- keyIdstring= "operator-1"
- The stable binding id. Re-registering the same keyId with a new publicKey is a rotation — append-only, the old binding is retired, never overwritten.
You register the public half only. HESO verifies delegations against it and never signs — it has nothing to sign with. Rotating a key retires the old binding without deleting the history, so the custody trail stays honest. Revoking is terminal: enrol a new keyId rather than reactivating.
Mark a rule client-clearable
A gate can only clear a policy rule you’ve marked as client-clearable. This is the explicit opt-in: it says “an end user, holding a delegated key, may co-sign actions that match this rule.” Everything else stays on your team’s approval path. Toggle it on the rule in Policy; the rule’s id becomes the gate’s scope.
The rule id travels with the gate as its scope, and the co-signature is bound to that scope. A gate minted for one rule cannot be reused to clear another — the bytes would no longer match. The backend re-derives the floor from the operator-signed verb on submit, so a customer can never approve past a pinned floor you set.
Open a gate for an action
With setup done, opening a gate is two server calls. First open an approval for the action; then mint a client token— a single-use bearer that’s good for about five minutes, plus the iframe URL to embed. Both calls run on your server with your API key; nothing here touches the browser yet.
// ON YOUR SERVER — never the browser.// These are SDK-ingestion routes (PLANE 2): authenticate with your API key in the// x-api-key header, not Authorization: Bearer.// 1) Open an approval for the action you're about to take. The body is the real// ApprovalOpenRequest: action_hash is required; rule_id is the client-clearable// rule (the gate's scope); is_floor marks a dangerous lane (payment / delete /// account_change / data_export) so the gate must be heso-rendered.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 client-clearable rule this gate clears is_floor: true, // dangerous lane => require the heso-rendered gate workflow, // optional metadata echoed onto the approval account, }),}).then((r) => r.json()) // open -> { approval_id, action_hash, status: "pending", threshold, approved_count } // 2) Mint a single-use, short-lived bearer + the iframe URL for the gate.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/…" }// The bearer is single-use (~300s); it is consumed when the iframe resolves the gate.
- POST /v1/approvals→ { approval_id, action_hash, status }
- Opens (or idempotently re-opens) the approval for the captured action. Body requires action_hash; rule_id and is_floor mark the gate's scope and 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 on resolve.
Embed it
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 the iframe 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 /v1/approvals/{action_hash}/client-token receipt, // the action your customer is reviewing scope, // the rule the gate clears (must match the envelope) // The gate mints the customer key K and publishes it; you sign an envelope // for THAT exact K on your server and return its base64 (see below). 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 gate 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 with CoSign(UnregisteredKey) — it fails closed. The bearer and the envelope reach the iframe over postMessage, never the URL.
Sign the delegation
The envelope is what lets the customer’s one-time key co-sign on your authority. You mint it server-side from your operator private key — once you know which key K the iframe published, inside the mintEnvelope callback above. @hesohq/sdk’s mintDelegationEnvelopeis one call — it assembles the wire (byte-pinned to the Rust core’s verify_delegation) and your signer signs it with your operator private key.
// ON YOUR SERVER — never the browser.import { mintDelegationEnvelope } from "@hesohq/sdk" // The signer wraps your operator PRIVATE key — server-only, never the browser. The// SDK assembles the wire (byte-pinned to the engine's verify_delegation); your signer// signs it. K is published by the gate iframe, so this runs in the mintEnvelope// callback once the iframe tells you which key will co-sign.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 scope: ruleId, // must match the rule the gate clears signer, // holds your operator PRIVATE key (server-only)})
- actionHashUint8Arrayrequired
- The raw 32-byte action digest this gate authorizes.
- authorizedKeyUint8Arrayrequired
- The customer's public key K, raw 32 bytes — published by the gate iframe.
- scopestringrequired
- The client-clearable rule id. 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) over the private key. 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 action they’re approving — the real receipt content, what-you-see-is-what-you-sign — 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-token. 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.
An approved receipt is only L1 if the delegation and the customer co-signature both verify. The cloud re-verifies before it accepts the token, and so should you — re-run offline verification before the action runs.
Branding and plans
On Free and Pro, the gate is the heso-branded embed shown here — copy-paste and ready. On Team and Custom, the gate can be themed and unbranded, and you get the API to drive it directly. The security is identical on every plan: same keys, same co-signature, same offline-verifiable receipt. Tier buys presentation, not trust.
