Next.js
Verify Action Receipts inside a Next.js App Router app, and gate your route handlers so an action runs only when the receipt clears your trust level.
A Next.js app has two runtimes, and HESO has a surface for each. On the server you verify a receipt and gate the handler before it acts. In the browser you verify with the same WASM core to show a user that a receipt checks out. No signing key ever reaches the client.
A receipt proves you authorized an action under your policy — and at L1 that a human approved it with a device-held key. It does not prove the action succeeded downstream.
Verify a receipt on the server
Use @hesohq/verify-wasm. It is verify-only, holds no private key, and runs the same Rust core in Node and the browser. The module is an async initializer plus synchronous named exports: await init() once, then call verifyActionReceipt with the receipt encoded as bytes.
import { NextResponse } from 'next/server'
import init, { verifyActionReceipt } from '@hesohq/verify-wasm'
// Load the wasm once per runtime. Cache the init() promise at module
// scope so a warm serverless/edge instance reuses it instead of
// re-fetching the binary on every request.
let ready: Promise<unknown> | null = null
function ensureWasm() {
if (!ready) ready = init()
return ready
}
export async function POST(req: Request) {
await ensureWasm()
const receipt = await req.json()
const bytes = new TextEncoder().encode(JSON.stringify(receipt))
const out = verifyActionReceipt(bytes) // ActionVerdict, sync after init
if (out.verdict !== 'Valid') {
return NextResponse.json(
{ ok: false, verdict: out.verdict }, // e.g. "HashMismatch"
{ status: 422 },
)
}
return NextResponse.json({ ok: true, trustLevel: out.trust_level }) // "L0" | "L1"
}verifyActionReceipt returns an ActionVerdict with two fields. out.verdict is the string "Valid" on success, or a failure variant: "HashMismatch", "InvalidSignature", "TrustLevelMismatch", "WrongAlgorithm", or "Malformed". out.trust_level is "L0" or "L1", re-derived from the signatures that actually verify. See offline verification for the full gate order.
The WASM functions take bytes, not JavaScript objects. Use new TextEncoder().encode(JSON.stringify(receipt)) so the canonical-bytes recomputation runs on the exact JSON the receipt was signed over. Passing the parsed object will fail.
init() on serverless and edge
Because the package targets the web, init() instantiates the .wasm at runtime rather than inlining it into your bundle. It must finish before any named export is called, and it is the expensive part — so run it once per runtime instance:
- Cache the
init()promise at module scope (theensureWasmhelper above). A warm serverless or edge instance reuses it; a cold start pays for it once. - Do not call a named export before
init()has resolved — it will throw.
Verify in a client component
The same module runs in the browser to show a user their receipt checks out, with no call back to HESO. Keep the import inside a "use client" module so the WASM never lands in the server bundle, and cache init() the same way.
'use client'
import { useEffect, useState } from 'react'
import init, { verifyActionReceipt, type ActionVerdict } from '@hesohq/verify-wasm'
let ready: Promise<unknown> | null = null
function ensureWasm() {
if (!ready) ready = init()
return ready
}
export function ReceiptBadge({ receipt }: { receipt: unknown }) {
const [out, setOut] = useState<ActionVerdict | null>(null)
useEffect(() => {
let alive = true
ensureWasm().then(() => {
const bytes = new TextEncoder().encode(JSON.stringify(receipt))
const v = verifyActionReceipt(bytes)
if (alive) setOut(v)
})
return () => {
alive = false
}
}, [receipt])
if (!out) return <span>Verifying…</span>
return (
<span>
{out.verdict} · {out.trust_level}
</span>
)
}Gate a route handler
Verifying tells you a receipt is genuine. Gating decides whether to act on it: it verifies the receipt and checks that the re-derived trust level meets your minimum before the handler does any work. Use @hesohq/sdk on the server for this.
assertGate(receipt, "L1") throws unless the receipt verifies and a human co-signed it. Put it ahead of anything irreversible — a payment, a delete — so the action runs only past the gate.
import { NextResponse } from 'next/server'
import { assertGate } from '@hesohq/sdk'
export async function POST(req: Request) {
const { receipt } = await req.json()
// Throws unless the receipt verifies AND a human co-signed it (L1).
// Money movement and destructive ops should require L1.
try {
assertGate(JSON.stringify(receipt), 'L1')
} catch {
return NextResponse.json({ error: 'gate_failed' }, { status: 403 })
}
await applyTransfer() // runs only past the gate
return NextResponse.json({ ok: true })
}If you would rather branch than throw, gate(receipt, "L0") returns a GateResult with allowed, trustLevel, and verdict. A blocked or tampered receipt comes back with allowed: false and a verdict naming the failed gate.
import { gate } from '@hesohq/sdk'
const r = gate(JSON.stringify(receipt), 'L0') // { allowed, trustLevel, verdict }
if (!r.allowed) {
// r.verdict names the failed gate, e.g. "InvalidSignature"
return NextResponse.json({ error: 'gate_failed', verdict: r.verdict }, { status: 403 })
}Do not trust a receipt’s claimed trust level. Get it from gate or assertGate, which re-derive it from the signatures that pass. A receipt claiming L1 with only an operator signature fails with TrustLevelMismatch.
In Node and the browser you verify and gate receipts. Capturing your own agent’s actions and signing them into receipts is done by the gating SDKs — see the TypeScript SDK and Python SDK.