Requesting and reading proofs
This page connects a Next.js App Router application to the TridentGold sandbox
verifier with the verifier SDK, @human.tech/tridentgold-zknc-mvp-verifier.
Your server requests a proof and gets back a wallet link and QR code for your
page; then it reads the result until the request is settled. The code is
complete and meant to be copied: with the SDK installed, it builds in a new
Next.js app without changes. File paths assume the project layout and @/
import alias that create-next-app sets up without a src/ directory.
What you can request describes the credential and the allowlist this code asks within. Verifier overview explains the flow and About this sandbox.
Install the SDK
The SDK is one package in the signed TridentGold SDK bundle, version
0.1.0-mvp.5. It is private: ask the TridentGold team for access. Then follow
SDK installation to download and verify it, keep the
tarball in your repository, and install it from there:
npm install --ignore-scripts \
./vendor/tridentgold/human.tech-tridentgold-zknc-mvp-verifier-0.1.0-mvp.5.tgz
The verifier SDK needs no other package from the bundle. Its one dependency,
uqr, comes from the public npm registry. Because the tarball is committed to
your repository, a deploy (for example on Vercel) installs it without a GitHub
token.
No API key or registration is needed to call the sandbox verifier.
Create the client
Call the SDK from server code only: route handlers, server actions or another backend. The result token it returns is a secret: keep it on your server.
This file creates the client, a small store for pending requests, an origin check and an error helper:
import { NextResponse, type NextRequest } from "next/server";
import {
createZkncMvpVerifier,
SANDBOX_VERIFIER_URL,
ZkncRateLimitedError,
ZkncRequestInvalidError,
ZkncRequestNotPermittedError,
ZkncResultNotFoundError,
ZkncUnavailableError
} from "@human.tech/tridentgold-zknc-mvp-verifier";
export const verifier = createZkncMvpVerifier({ baseUrl: SANDBOX_VERIFIER_URL });
/**
* The cookie holds only an opaque key for the browser session that created
* the request. The request id and result token stay on the server.
*/
export const SESSION_COOKIE = "verification_session";
export type StoredProofRequest = {
requestId: string;
resultToken: string;
expiresAt: string;
};
export type ProofRequestStore = {
save(sessionKey: string, request: StoredProofRequest): Promise<void>;
load(sessionKey: string): Promise<StoredProofRequest | undefined>;
/**
* Removes the session's request and records its request id as used.
* Resolves true only the first time for a given request id.
*/
consume(sessionKey: string, requestId: string): Promise<boolean>;
};
/**
* Example only: replace with your database or Redis. An in-memory map works
* only in a single long-running server process (`next dev`, `next start`),
* not on serverless hosting such as Vercel, where each request can reach a
* different instance.
*/
function inMemoryStore(): ProofRequestStore {
const requests = new Map<string, StoredProofRequest>();
const used = new Map<string, number>();
const sweep = () => {
const cutoff = Date.now() - 10 * 60_000;
for (const [key, request] of requests) {
if (Date.parse(request.expiresAt) < cutoff) requests.delete(key);
}
for (const [id, at] of used) if (at < cutoff) used.delete(id);
};
return {
async save(sessionKey, request) {
sweep();
requests.set(sessionKey, request);
},
async load(sessionKey) {
return requests.get(sessionKey);
},
async consume(sessionKey, requestId) {
requests.delete(sessionKey);
if (used.has(requestId)) return false;
used.set(requestId, Date.now());
return true;
}
};
}
export const proofRequests: ProofRequestStore = inMemoryStore();
/** Refuses cross-site POSTs. Browsers send Origin on every POST. */
export function isSameOrigin(request: NextRequest): boolean {
return request.headers.get("origin") === request.nextUrl.origin;
}
/**
* Maps SDK errors to responses for your page. SDK errors never contain the
* result token, the request body or a credential value, so they are safe to log.
*/
export function verifierErrorResponse(error: unknown): NextResponse {
const noStore = { "Cache-Control": "no-store" };
if (error instanceof ZkncRateLimitedError) {
return NextResponse.json(
{ error: "rate_limited" },
{ status: 429, headers: { ...noStore, "Retry-After": String(error.retryAfterSeconds) } }
);
}
if (error instanceof ZkncUnavailableError) {
console.error("TridentGold verifier unavailable", error.code, error.status);
return NextResponse.json({ error: "unavailable" }, { status: 503, headers: noStore });
}
if (error instanceof ZkncResultNotFoundError) {
return NextResponse.json({ error: "not_found" }, { status: 404, headers: noStore });
}
if (error instanceof ZkncRequestNotPermittedError) {
// The request is outside the sandbox allowlist: a bug in your request, not the user's.
console.error("TridentGold request refused", error.rule, error.source, error.message);
return NextResponse.json({ error: "request_refused" }, { status: 500, headers: noStore });
}
if (error instanceof ZkncRequestInvalidError) {
console.error("TridentGold request invalid", error.message);
return NextResponse.json({ error: "request_invalid" }, { status: 500, headers: noStore });
}
throw error;
}
SANDBOX_VERIFIER_URL is the hosted sandbox verifier. createZkncMvpVerifier
also takes:
fetch: a custom fetch implementation.timeoutMs: the timeout for each HTTP call, default 10,000.requestProof()can make two calls, one to fetch the allowlist and one to create the request, and each has its own timeout. Passing{ signal }to a call replaces the timeout for that call's own request; the allowlist fetch always usestimeoutMs.policyCacheSeconds: how long the allowlist is cached for checking requests before sending them, default 60;0fetches it every time.
Replace the in-memory store before you deploy. It keeps pending requests
in the memory of one server process, so it works with next dev and
next start but not on serverless hosting such as Vercel, where requests can
reach different instances. Implement the same three methods on your database
or Redis.
The error helper maps SDK errors to responses your page can act on; Errors describes each one.
Request a proof
This route handler asks for record_status to be disclosed, proves that
decision_date falls within the last 180 days, and requests a nullifier with
the scope label volunteer-screening. It stores the request id and result
token on the server under a new random session key, gives the browser only
that key in an httpOnly cookie, and returns the wallet links, the QR code and
the expiry to the page.
import { randomBytes } from "node:crypto";
import { NextResponse, type NextRequest } from "next/server";
import { policeCertificate } from "@human.tech/tridentgold-zknc-mvp-verifier";
import {
isSameOrigin,
proofRequests,
SESSION_COOKIE,
verifier,
verifierErrorResponse
} from "@/lib/tridentgold-verifier";
export async function POST(request: NextRequest) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}
// In your app, also require your own signed-in session here and apply a
// per-user rate limit before calling the verifier.
try {
const created = await verifier.requestProof(
policeCertificate({
purpose: "Confirm a clear record for volunteer screening",
disclose: ["record_status"],
decidedWithin: { days: 180 },
nullifier: { scopeLabel: "volunteer-screening" }
})
);
// Keep the request id and result token on the server, keyed by a new
// opaque session key. Use your own session or transaction id if you have one.
const sessionKey = randomBytes(32).toString("base64url");
await proofRequests.save(sessionKey, {
requestId: created.requestId,
resultToken: created.resultToken,
expiresAt: created.expiresAt
});
// Only the wallet links, the QR code and the expiry go to the page.
const response = NextResponse.json(
{
qrSvg: created.qrSvg,
universalLink: created.wallet.universalLink,
customSchemeLink: created.wallet.customSchemeLink,
expiresAt: created.expiresAt
},
{ headers: { "Cache-Control": "no-store" } }
);
response.cookies.set(SESSION_COOKIE, sessionKey, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/api/proof-result",
expires: new Date(Date.parse(created.expiresAt) + 60_000)
});
return response;
} catch (error) {
return verifierErrorResponse(error);
}
}
requestProof() returns:
| Field | What it is |
|---|---|
requestId | Identifies the request. It is not secret: it also appears in the wallet links and the QR code. |
resultToken | The secret that reads the result, and the only way to read it. Never log it or expose it to page scripts. |
expiresAt | When the request expires, as an RFC 3339 timestamp. A request lasts 300 seconds, the verifier session lifetime. |
wallet.universalLink | An https link that opens the request in the TridentGold app. |
wallet.customSchemeLink | A tridentgold-holder:// link, for a button on the same phone. |
qrSvg | A standalone SVG QR code of wallet.universalLink, drawn by the SDK. Show it at 200 CSS pixels or larger. |
request | The parts of the signed request you control, as signed, including the DCQL query that was sent. See See what was sent. |
To send the same request as raw DCQL and predicates rather than with the
builder, pass the object from Write the same request as raw
DCQL to requestProof()
instead.
Protect the request route
Anyone who can reach this route can make your server spend its share of the sandbox verifier's rate limit. In your application:
- put it behind your own signed-in session, and key the stored request by that session or by the transaction that needs the check, rather than by a new random key;
- keep the origin check, or use your framework's CSRF protection, so another site cannot start requests from your users' browsers;
- rate-limit it per user.
Read the result
The page polls this route every few seconds. Each call makes one short request to the verifier; there is no long polling. It reads the result only for the browser session that created the request, and uses each settled result once.
import { NextResponse, type NextRequest } from "next/server";
import {
ZkncRateLimitedError,
ZkncResultNotFoundError
} from "@human.tech/tridentgold-zknc-mvp-verifier";
import {
proofRequests,
SESSION_COOKIE,
verifier,
verifierErrorResponse
} from "@/lib/tridentgold-verifier";
const noStore = { "Cache-Control": "no-store" };
function noRequest() {
const response = NextResponse.json({ status: "no_request" }, { status: 404, headers: noStore });
response.cookies.delete({ name: SESSION_COOKIE, path: "/api/proof-result" });
return response;
}
export async function GET(request: NextRequest) {
const sessionKey = request.cookies.get(SESSION_COOKIE)?.value;
const stored = sessionKey ? await proofRequests.load(sessionKey) : undefined;
if (!sessionKey || !stored) return noRequest();
let body: Record<string, unknown>;
try {
const result = await verifier.getResult(stored.requestId, stored.resultToken);
if (result.status === "pending") {
return NextResponse.json({ status: "pending" }, { headers: noStore });
}
// Settled. Use each request's result at most once, and only for the
// session that created it.
if (!(await proofRequests.consume(sessionKey, stored.requestId))) return noRequest();
switch (result.status) {
case "verified":
// Every predicate in a verified result is true. Apply the result to
// the transaction that created this request, here, on the server.
// result.nullifier is the same each time this holder answers a
// "volunteer-screening" request.
body = {
status: "verified",
recordStatus: result.disclosed.record_status,
decisionDateInRange: result.predicates.decision_date_in_range === true
};
break;
case "rejected":
body = {
status: "rejected",
category: result.reason.category,
message: result.reason.message,
retryable: result.reason.retryable
};
break;
case "expired":
// No answer. Never treat it as a "no".
body = { status: "expired" };
break;
}
} catch (error) {
// Being rate-limited is not an answer: tell the page to keep waiting.
if (error instanceof ZkncRateLimitedError) {
return NextResponse.json(
{ status: "pending" },
{ headers: { ...noStore, "Retry-After": String(error.retryAfterSeconds) } }
);
}
if (error instanceof ZkncResultNotFoundError) {
await proofRequests.consume(sessionKey, stored.requestId);
return noRequest();
}
return verifierErrorResponse(error);
}
const response = NextResponse.json(body, { headers: noStore });
response.cookies.delete({ name: SESSION_COOKIE, path: "/api/proof-result" });
return response;
}
A result is one of four statuses:
| Status | Meaning | What to do |
|---|---|---|
pending | No answer yet. | Poll again in a few seconds, until expiresAt. |
verified | The proof verified. disclosed holds the claims you asked to have disclosed, keyed by claim name. predicates holds each predicate you asked for, keyed by its id, and every one is true. nullifier is present if you asked for one. credential records the issuer, schema and the status check the verifier made. | Apply the result to the matching transaction and make your own decision. |
rejected | A presentation arrived and the verifier refused it. reason says why; see Rejection categories. | Do not treat the evidence as established. |
expired | No answer before the request expired. | Treat it as "no answer", never as "no". Offer a new request. |
expired never means "no". A holder whose credential does not satisfy
every predicate cannot produce a proof, and the wallet does not tell the
verifier when a person declines. In both cases, and when the person simply
does not respond, the request stays pending until it expires. There is no
result that says a person does not meet your conditions.
Results are removed some time after the request expires. After that, and for a
wrong request id or result token, getResult() throws
ZkncResultNotFoundError.
Apply a result once, to the transaction that created it
A verified result shows that some holder answered this request. It does not show who is at the browser that polls for it. So:
- bind each request to the session or transaction that created it, and read its result only for that session;
- apply each settled request at most once: record the request ids you have
used, and refuse one you have seen before (the sample's
consume()does this); - apply the result only to the transaction that created the request, never to another account or a later transaction.
Show the request on your page
A minimal client page that starts a request, shows the QR code and a link for
the same phone, and polls the result route. It keeps polling through network
failures and busy responses, waits as long as Retry-After asks, and stops a
minute after the request expires. The link uses wallet.universalLink, which
is what the QR code encodes, with wallet.customSchemeLink as a fallback.
"use client";
import { useEffect, useState } from "react";
type Started = {
qrSvg: string;
universalLink: string;
customSchemeLink: string;
expiresAt: string;
};
type Outcome =
| { status: "pending" }
| { status: "verified"; recordStatus: string }
| { status: "rejected"; message: string; retryable: boolean }
| { status: "expired" }
| { status: "error"; message: string };
const POLL_MS = 3_000;
/** Stop polling this long after the request expires, if nothing settled it. */
const GIVE_UP_AFTER_EXPIRY_MS = 60_000;
/** Milliseconds from a Retry-After header in seconds, or the default. */
function retryDelay(response: Response): number {
const seconds = Number(response.headers.get("retry-after"));
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1_000 : POLL_MS;
}
export default function VerifyPage() {
const [started, setStarted] = useState<Started | null>(null);
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [poll, setPoll] = useState({ count: 0, delay: POLL_MS });
async function start() {
setStarted(null);
setOutcome(null);
let response: Response;
try {
response = await fetch("/api/proof-request", { method: "POST" });
} catch {
setOutcome({ status: "error", message: "Could not reach this site. Check your connection." });
return;
}
if (!response.ok) {
setOutcome({
status: "error",
message:
response.status === 429 || response.status === 503
? "The verifier is busy or unavailable. Try again shortly."
: "Could not start a request."
});
return;
}
setStarted((await response.json()) as Started);
setOutcome({ status: "pending" });
setPoll({ count: 0, delay: POLL_MS });
}
useEffect(() => {
if (!started || outcome?.status !== "pending") return;
const timer = setTimeout(async () => {
const next = (delay: number) => setPoll((p) => ({ count: p.count + 1, delay }));
if (Date.now() > Date.parse(started.expiresAt) + GIVE_UP_AFTER_EXPIRY_MS) {
setOutcome({ status: "error", message: "No result could be read. Start a new request." });
return;
}
let response: Response;
try {
response = await fetch("/api/proof-result", { cache: "no-store" });
} catch {
// A network failure is not an answer; try again.
next(POLL_MS);
return;
}
if (response.status === 503 || response.status === 429) {
// The verifier is unavailable or busy. That is not an answer; keep waiting.
next(retryDelay(response));
return;
}
if (!response.ok) {
setOutcome({ status: "error", message: "This request is no longer available. Start again." });
return;
}
const result = (await response.json()) as Outcome;
if (result.status === "pending") next(retryDelay(response));
else setOutcome(result);
}, poll.delay);
return () => clearTimeout(timer);
}, [started, outcome, poll]);
return (
<main>
<h1>Volunteer screening</h1>
{outcome?.status !== "pending" && (
<button onClick={start}>Request a proof</button>
)}
{started && outcome?.status === "pending" && (
<>
<p>Scan with the TridentGold app, or open it on this phone.</p>
<img
src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(started.qrSvg)}`}
width={240}
height={240}
alt="QR code that opens this request in the TridentGold app"
/>
<p>
<a href={started.universalLink}>Open in TridentGold</a>
</p>
<p>
If the app does not open, <a href={started.customSchemeLink}>try this link instead</a>.
</p>
<p>Waiting for the wallet. This request expires at {new Date(started.expiresAt).toLocaleTimeString()}.</p>
</>
)}
{outcome?.status === "verified" && <p>Verified. Record status: {outcome.recordStatus}.</p>}
{outcome?.status === "rejected" && (
<p>
{outcome.message} {outcome.retryable ? "You can start a new request." : ""}
</p>
)}
{outcome?.status === "expired" && (
<p>No answer before the request expired. You can start a new request.</p>
)}
{outcome?.status === "error" && <p>{outcome.message}</p>}
</main>
);
}
The QR code is rendered as an image from the SVG text, which keeps the SVG out of your page's DOM.
Rejection categories
A rejection's reason has a category, a readable message, a retryable
flag and the raw verifier code. Branch on category or retryable; the raw
code is for debugging and the set of codes may change.
| Category | What it means | Retryable | What to do |
|---|---|---|---|
credential_not_accepted | The credential, or the kind of proof the wallet sent, is not one this verifier accepts. | No | Tell the person this credential cannot be used here. Retrying with the same credential gives the same result. |
status_out_of_date | The credential's status information was older than the verifier accepts, or the presentation's time was outside the verifier's clock tolerance. | Yes | Ask the person to refresh the credential's status in the app, and start a new request. |
proof_invalid | The proof did not verify, or what it proved does not match the request. A code the SDK does not recognize is also reported here. | No | Do not treat anything as established. If it keeps happening, report it to the TridentGold team with the raw code. |
request_mismatch | The presentation did not belong to this request: for example it answered another request, or arrived after the request was used or had ended. | Yes, with a new request | Start a new request. |
unavailable | The verifier could not complete verification. This says nothing about the credential. | Yes | Start a new request later. |
REJECTION_CATEGORIES and categorizeRejection(code) are exported if you
need the raw codes behind each category.
Errors
Calls throw typed errors instead of returning a result. All of them extend
ZkncVerifierError, carry a stable code and, when they came from a
response, the HTTP status. None contains a result token, a request body or a
credential value, so they are safe to log.
| Error | When | What to do |
|---|---|---|
ZkncRequestNotPermittedError | The request is outside the sandbox allowlist. rule names the rule that failed (see The allowlist); source is "policy-check" if the SDK caught it before sending, or "verifier". | Change the request. It is a bug in your code, not something the person did. |
ZkncRequestInvalidError | The request is malformed, or an argument is not one the SDK can send. | Fix the code. |
ZkncRateLimitedError | The verifier rate-limited your server. retryAfterSeconds says how long to wait. | Wait, then retry. While reading a result, it is not an answer: keep the request pending. |
ZkncUnavailableError | The verifier could not be reached, timed out, returned a server error, or returned a response outside the contract (code: "response_invalid"). | Retry later. It is never a verdict about the credential. |
ZkncResultNotFoundError | getResult() found no result: the id or token is wrong, or the result was removed after expiry. | Start a new request. |
Rate limits
The sandbox verifier allows 20 new requests and 300 result reads per minute
(policy.rateLimits from getPolicy()), counted in fixed one-minute windows
per IPv4 address or IPv6 /64.
Two more limits apply to requests that have not been answered yet:
- At most 16 unanswered requests per client address. A further request
gets
ZkncRateLimitedError(HTTP 429 withRetry-After) until the oldest of them expires. - A sandbox-wide cap on unanswered requests from all integrators
together. When it is reached, new requests get
ZkncUnavailableError(HTTP 503). Retry later.
With a 300-second request lifetime, the per-address limit means one server address can have at most 16 people part-way through a check at once.
Credential status and revocation
A credential may have been revoked since it was issued. The verifier checks
the credential's status as part of checking the proof, and a verified result
records which status publication it checked, and when, in
result.credential.status. If the verifier cannot authenticate the status
information it needs, the result is not verified.
A request uses the status information current when it was created, and keeps that allowance until it expires. To see a newly published revocation, create a new request.
Verification is a decision at a particular time. Revocation does not end a session or withdraw access your application has already granted; you decide when a further check is needed. The issuer's side of this is in the issuer status guidance.
The HTTP API underneath
The SDK is the supported way to call the verifier. For reference, it uses three routes on the sandbox verifier:
| Route | Purpose |
|---|---|
GET /v1/policy | The allowlist, machine-readable. |
POST /v1/proof-requests | Create a request. No authentication; rate-limited per IP address. |
GET /v1/proof-requests/{request_id}/result | Read the result, with Authorization: Bearer <result_token>. An unknown id and a wrong token both return 404. |
The JSON uses snake_case names, such as dcql_query and result_token, where
the SDK uses camelCase.
See What works today for the scope of the sandbox.