Skip to main content

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:

lib/tridentgold-verifier.ts
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 uses timeoutMs.
  • policyCacheSeconds: how long the allowlist is cached for checking requests before sending them, default 60; 0 fetches 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.

app/api/proof-request/route.ts
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:

FieldWhat it is
requestIdIdentifies the request. It is not secret: it also appears in the wallet links and the QR code.
resultTokenThe secret that reads the result, and the only way to read it. Never log it or expose it to page scripts.
expiresAtWhen the request expires, as an RFC 3339 timestamp. A request lasts 300 seconds, the verifier session lifetime.
wallet.universalLinkAn https link that opens the request in the TridentGold app.
wallet.customSchemeLinkA tridentgold-holder:// link, for a button on the same phone.
qrSvgA standalone SVG QR code of wallet.universalLink, drawn by the SDK. Show it at 200 CSS pixels or larger.
requestThe 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.

app/api/proof-result/route.ts
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:

StatusMeaningWhat to do
pendingNo answer yet.Poll again in a few seconds, until expiresAt.
verifiedThe 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.
rejectedA presentation arrived and the verifier refused it. reason says why; see Rejection categories.Do not treat the evidence as established.
expiredNo 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.

app/verify/page.tsx
"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.

CategoryWhat it meansRetryableWhat to do
credential_not_acceptedThe credential, or the kind of proof the wallet sent, is not one this verifier accepts.NoTell the person this credential cannot be used here. Retrying with the same credential gives the same result.
status_out_of_dateThe credential's status information was older than the verifier accepts, or the presentation's time was outside the verifier's clock tolerance.YesAsk the person to refresh the credential's status in the app, and start a new request.
proof_invalidThe proof did not verify, or what it proved does not match the request. A code the SDK does not recognize is also reported here.NoDo not treat anything as established. If it keeps happening, report it to the TridentGold team with the raw code.
request_mismatchThe 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 requestStart a new request.
unavailableThe verifier could not complete verification. This says nothing about the credential.YesStart 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.

ErrorWhenWhat to do
ZkncRequestNotPermittedErrorThe 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.
ZkncRequestInvalidErrorThe request is malformed, or an argument is not one the SDK can send.Fix the code.
ZkncRateLimitedErrorThe 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.
ZkncUnavailableErrorThe 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.
ZkncResultNotFoundErrorgetResult() 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 with Retry-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:

RoutePurpose
GET /v1/policyThe allowlist, machine-readable.
POST /v1/proof-requestsCreate a request. No authentication; rate-limited per IP address.
GET /v1/proof-requests/{request_id}/resultRead 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.