Guides /Next.js

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.

What a receipt proves

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.

app/api/verify/route.tsts
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.

Encode the receipt, don't pass the object

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 (the ensureWasm helper 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.

components/receipt-badge.tsxtsx
'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.

app/api/transfer/route.tsts
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.

ts
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 })
}
Never read trust_level off the wire

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.

Capturing and signing happens elsewhere

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.

Next steps