Skip to main content

Creating invitations

Once your schema is published, your server creates an invitation for each tester: it sends the credential's values to the Tenant Gateway and gets back a one-time code for the tester to type into the TridentGold app. This page connects a Next.js App Router application to the gateway with the issuer SDK, @human.tech/tridentgold-zknc-mvp-issuer. File paths assume the project layout and @/ import alias that create-next-app sets up without a src/ directory.

Defining a schema covers the schema these examples use, a synthetic training record with the attributes level, completed_on and certificate_no. Issuer overview explains the flow and About this sandbox.

Install the SDK​

The issuer SDK is part of the signed TridentGold SDK bundle from version 0.1.0-mvp.5, a private prerelease: ask your contact for access. Then follow SDK installation to download and verify it, keep the tarballs in your repository, and install the issuer SDK together with the package it depends on:

npm install --ignore-scripts \
./vendor/tridentgold/human.tech-tridentgold-zknc-mvp-client-0.1.0-mvp.5.tgz \
./vendor/tridentgold/human.tech-tridentgold-zknc-mvp-issuer-0.1.0-mvp.5.tgz

Its other dependency, uqr, comes from the public npm registry.

Call the SDK from server code only. Every call carries your API key, and the package refuses to create a client in a browser bundle. Put the values your contact gave you in your server's environment, for example .env.local for local development and your host's secret settings in production:

.env.local
ZKNC_TENANT_GATEWAY_URL=<the gateway URL from your contact>
ZKNC_TENANT_API_KEY=<your tgk_ API key>
ZKNC_TENANT_SCHEMA_ID=<your published schema's schema_id>

Create the client​

This file creates the client on first use, reads your schema's id, checks who is calling, and maps SDK errors to responses for your page:

lib/tridentgold-issuer.ts
import { NextResponse, type NextRequest } from "next/server";
import {
createZkncTenantIssuer,
ZkncClaimsInvalidError,
ZkncTenantNotFoundError,
ZkncTenantRateLimitedError,
ZkncTenantRefusedError,
ZkncTenantUnavailableError,
type ZkncTenantIssuer
} from "@human.tech/tridentgold-zknc-mvp-issuer";

let client: ZkncTenantIssuer | undefined;

/** The Tenant Gateway client, created on first use from server environment variables. */
export function tenantIssuer(): ZkncTenantIssuer {
if (client === undefined) {
const baseUrl = process.env.ZKNC_TENANT_GATEWAY_URL;
const apiKey = process.env.ZKNC_TENANT_API_KEY;
if (!baseUrl || !apiKey) {
// Name the variables, never their values.
throw new Error("Set ZKNC_TENANT_GATEWAY_URL and ZKNC_TENANT_API_KEY on the server.");
}
client = createZkncTenantIssuer({ baseUrl, apiKey });
}
return client;
}

/** The schema_id your submission returned. */
export function trainingRecordSchemaId(): number {
const schemaId = Number(process.env.ZKNC_TENANT_SCHEMA_ID);
if (!Number.isSafeInteger(schemaId) || schemaId <= 0) {
throw new Error("Set ZKNC_TENANT_SCHEMA_ID on the server.");
}
return schemaId;
}

/** Refuses cross-site POSTs. Browsers send Origin on every POST. This is not authorization. */
export function isSameOrigin(request: NextRequest): boolean {
return request.headers.get("origin") === request.nextUrl.origin;
}

export type StaffMember = { id: string };

/**
* REPLACE WITH YOUR OWN SESSION CHECK. Return the signed-in staff member who
* may create invitations, or undefined. Until you replace it, it returns
* undefined, so every invitation route answers 401: whoever passes this check
* can create invitations with your API key.
*/
export async function requireStaff(_request: NextRequest): Promise<StaffMember | undefined> {
return undefined;
}

const INVITATIONS_PER_STAFF_PER_HOUR = 20;
const recentInvitations = new Map<string, number[]>();

/**
* A per-staff-member limit on new invitations. Example only: it keeps counts
* in one server process's memory, so on serverless hosting such as Vercel,
* keep them in your database or Redis instead.
*/
export function allowInvitation(staff: StaffMember): boolean {
const now = Date.now();
const recent = (recentInvitations.get(staff.id) ?? []).filter((at) => at > now - 3_600_000);
const allowed = recent.length < INVITATIONS_PER_STAFF_PER_HOUR;
if (allowed) recent.push(now);
recentInvitations.set(staff.id, recent);
return allowed;
}

/**
* Maps SDK errors to responses for your page. SDK errors never contain the
* API key, a claim value, an invitation code or a request body, so they are
* safe to log.
*/
export function issuerErrorResponse(error: unknown): NextResponse {
const noStore = { "Cache-Control": "no-store" };
if (error instanceof ZkncClaimsInvalidError) {
// A value does not fit the schema: path names the attribute.
return NextResponse.json(
{ error: error.code, path: error.path ?? null },
{ status: 400, headers: noStore }
);
}
if (error instanceof ZkncTenantRateLimitedError) {
const headers: Record<string, string> =
error.retryAfterSeconds === undefined
? noStore
: { ...noStore, "Retry-After": String(error.retryAfterSeconds) };
return NextResponse.json({ error: error.code }, { status: 429, headers });
}
if (error instanceof ZkncTenantNotFoundError) {
return NextResponse.json({ error: "not_found" }, { status: 404, headers: noStore });
}
if (error instanceof ZkncTenantRefusedError) {
// For example tenant_unauthorized, or a schema that is not published yet.
console.error("TridentGold gateway refused", error.code, error.source, error.path ?? "");
return NextResponse.json({ error: "issuance_refused" }, { status: 500, headers: noStore });
}
if (error instanceof ZkncTenantUnavailableError) {
console.error("TridentGold gateway unavailable", error.code, error.status);
return NextResponse.json({ error: "unavailable" }, { status: 503, headers: noStore });
}
throw error;
}

createZkncTenantIssuer takes:

OptionDefaultMeaning
baseUrlRequired.The gateway URL: https://, with no path.
apiKeyRequired.Your tgk_ key. A malformed key throws ZkncTenantConfigError, which never repeats the key.
fetchThe global fetch.A custom fetch implementation.
timeoutMs15,000The timeout for each HTTP call. Passing { signal } to a call replaces it for that call.
checkDraftsLocallytrueCheck a schema draft before submitSchema() sends it.

The client sends your key only to baseUrl and refuses redirects.

Create an invitation​

This route handler takes the three values for one tester from your page, creates an invitation, and returns the code, a QR code and the expiry:

app/api/invitations/route.ts
import { NextResponse, type NextRequest } from "next/server";
import {
allowInvitation,
isSameOrigin,
issuerErrorResponse,
requireStaff,
tenantIssuer,
trainingRecordSchemaId
} from "@/lib/tridentgold-issuer";

export async function POST(request: NextRequest) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}
const staff = await requireStaff(request);
if (!staff) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
if (!allowInvitation(staff)) {
return NextResponse.json({ error: "too_many_invitations" }, { status: 429 });
}

let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
}
const { level, completedOn, certificateNo } = (body ?? {}) as Record<string, unknown>;
if (typeof level !== "string" || typeof completedOn !== "string" || typeof certificateNo !== "string") {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}

try {
const created = await tenantIssuer().createInvitation({
schemaId: trainingRecordSchemaId(),
claims: { level, completed_on: completedOn, certificate_no: certificateNo }
});
// Never log the invitation code or the QR code: they are the tester's.
return NextResponse.json(
{
invitationId: created.invitationId,
invitationCode: created.invitationCode,
qrSvg: created.qrSvg,
expiresAt: created.expiresAt
},
{ status: 201, headers: { "Cache-Control": "no-store" } }
);
} catch (error) {
return issuerErrorResponse(error);
}
}

Replace requireStaff() before you use this route. As written it refuses every request with 401. The Origin check is not authorization: a client outside a browser can send any Origin header, so the check only stops other sites from using your staff's browsers. Whoever passes requireStaff() can create invitation codes with your API key and use up your organization's invitation limit, so return a staff member only for a signed-in session that may create invitations. allowInvitation() then limits each staff member to 20 new invitations an hour; choose your own limit.

A request body for it looks like this, with synthetic values only:

{ "level": "advanced", "completedOn": "2026-09-01", "certificateNo": "SANDBOX-0001" }

createInvitation() returns:

FieldWhat it is
invitationId64 hexadecimal characters. Not secret. Use it to check the invitation's status; in your app, keep it with your own record of the invitation.
invitationCodeThe code the tester types into the app. It works once. The gateway does not keep it, so this is the only time you see it. Give it only to the intended tester, and never log it.
qrSvgA standalone SVG QR code. Today it contains the invitation code itself; treat it like the code.
qrContentWhat the QR code contains: "invitation-code" today.
walletLinksAlways an empty array today.
expiresAtWhen the code expires, in Unix seconds.
schema{ id, version } of the schema.

Claims​

claims names every attribute of your schema except the slot 0 subject reference, which the gateway generates for each invitation. The values follow Values by type: a string starts SANDBOX-, is printable ASCII and fits max_length; a uint is decimal text of at most max_digits digits; an enum value is a member name; a date is YYYY-MM-DD; a datetime is YYYY-MM-DDTHH:MM:SS, with an optional fraction, then Z or an offset ±HH:MM; a bool is a JSON boolean. The SDK and the gateway check the shape of dates and date-times; the sandbox issuer checks that the day and time exist (so 2026-02-31 or 99:99:99 passes the first check and is refused by the issuer). Either way the code is invitation_claim_format. Nothing else is accepted, and the values must be synthetic; see About this sandbox.

Before sending, the SDK reads your schema once (then caches it for the client's lifetime) and checks the claims. A value that does not fit throws ZkncClaimsInvalidError with path set to /claims/<attribute> (no path for an attribute the schema does not have); the error never contains the value. Pass { checkClaims: false } as the second argument to skip the read and let the gateway decide.

The tester enters the code​

Show the code on your page, or give it to the tester another way. In the TridentGold app, the tester:

  1. chooses Add credential;
  2. under Invitation code, chooses Enter code;
  3. types the code and taps Get credential, keeping the app open while it checks the code, prepares the credential and saves it to the wallet.

The tester has to type the code. The app cannot scan an invitation QR code, and the gateway returns no wallet links yet, so there is no link that opens the app. The QR code only lets the tester read the code with a phone camera; they still type it into the app. When wallet links are added, the QR code will contain a link instead, and qrContent will be "wallet-link".

The tester needs an app build that can receive credentials for your schema; see About this sandbox.

Track an invitation​

getInvitation(invitationId) returns the invitation's current state, read from the sandbox issuer. It never returns claims.

app/api/invitations/[invitationId]/route.ts
import { NextResponse, type NextRequest } from "next/server";
import { issuerErrorResponse, requireStaff, tenantIssuer } from "@/lib/tridentgold-issuer";

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ invitationId: string }> }
) {
if (!(await requireStaff(request))) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
const { invitationId } = await params;
if (!/^[0-9a-f]{64}$/.test(invitationId)) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
try {
const invitation = await tenantIssuer().getInvitation(invitationId);
return NextResponse.json(
{ state: invitation.state, expiresAt: invitation.expiresAt },
{ headers: { "Cache-Control": "no-store" } }
);
} catch (error) {
return issuerErrorResponse(error);
}
}
StateMeaning
availableNot used yet, and not expired.
redeemedThe tester entered the code; no credential has been signed yet.
issuedA credential was signed; its activation has not been confirmed yet.
activatedThe credential is active in the tester's app. A later revocation or supersession does not change this state.
expiredThe code was not used before expiresAt.

Poll every few seconds at most: each call counts toward your key's rate limit. The sandbox issuer stops keeping an invitation some time after it expires or is used; after that, getInvitation() throws ZkncTenantNotFoundError, so record the last state you read.

Revocation​

The Tenant Gateway and the issuer SDK have no revocation method. Revocation in the sandbox is performed by the TridentGold team; ask your contact whether it is available for credentials under your schema. Deleting a credential from a phone is not the same as revoking it. A verifier request created before a revocation keeps its original status allowance until it expires.

Errors​

Every SDK error extends ZkncTenantError and has a stable code. They fall into two families, which tell you whether to fix the request or try again later. No error contains your API key, a claim value, an invitation code or a request body, so they are safe to log.

Refused: fix the request. ZkncTenantRefusedError and its subclasses. source is "local-check" when the SDK refused before sending anything, or "gateway". Sending the same request again does not help, except after a rate limit.

ErrorCodesWhat to do
ZkncSchemaInvalidErrorschema_request_invalid, schema_name_invalid, schema_invalidFix the draft at path. For schema_invalid, findings lists every rule that failed, with its path.
ZkncClaimsInvalidErrorinvitation_claim_missing, _unknown, _type, _synthetic, _too_long, _enum, _format, _ref_supplied, or a claim refusal from the sandbox issuerFix the value at path.
ZkncTenantUnauthorizedErrortenant_unauthorizedYour key is malformed, unknown or revoked, or your organization's access is disabled. Ask your contact.
ZkncTenantNotFoundErrorschema_unknown, invitation_unknownNo such schema or invitation belongs to your organization.
ZkncTenantRateLimitedErrortenant_rate_limited, schema_pending_cap, tenant_invitation_cap, invitation_credential_outstanding_rate_limitedWait retryAfterSeconds. The gateway sends it with its own rate limit and both caps (an hour for schema_pending_cap, a minute for tenant_invitation_cap); a cap may lift sooner, when a pending schema is decided or an outstanding invitation is used or expires.
ZkncTenantRefusedErrorFor example invitation_schema_not_approved, schema_name_taken, trust_schema_unauthorized, trust_registry_unknown, trust_registry_suite_mismatch, invitation_store_exhausted, request_too_large, transfer_encoding_unsupported, argument_invalidRead code. invitation_schema_not_approved means the schema is pending, rejected or withdrawn. trust_schema_unauthorized means it is approved but not published yet. trust_registry_unknown and trust_registry_suite_mismatch mean the published configuration for the schema is wrong; report them to the TridentGold team rather than waiting.

Unavailable: try again later. ZkncTenantUnavailableError means the gateway, or the sandbox issuer behind it, could not answer: a network failure or timeout, a server error such as storage_unavailable, issuer_unavailable or issuer_refused, or a response the SDK could not read (response_invalid). It is never a verdict on your request. If the call was createInvitation(), no code reached you, so create a new invitation. The sandbox issuer may still have created the lost one, which then counts toward the limits below until it expires.

The gateway takes one request per connection and needs a request body sent with Content-Length. A body sent with Transfer-Encoding, such as chunked, is refused with transfer_encoding_unsupported. The SDK sends Content-Length with the global fetch; check this if you pass a custom fetch that streams request bodies.

ZkncTenantConfigError (config_invalid) means the client options are unusable, for example an API key that is not a tgk_ key.

Limits. Each API key has a request rate limit per minute (60 by default). Each organization has a limit on outstanding invitations: created, not expired and not yet used (50 by default), and on schemas waiting for approval (five by default). The sandbox issuer also limits outstanding invitations across all organizations. Your contact can tell you the values that apply to you.

Check a credential under your schema​

A verifier can ask a tester's app for proof about a credential under your schema with the verifier SDK, @human.tech/tridentgold-zknc-mvp-verifier. Requesting and reading proofs covers installing it and the Next.js route handlers. request() and the per-schema policy need the verifier SDK from 0.1.0-mvp.5, the version that guide installs; 0.1.0-mvp.4 does not have them.

To ask for a credential under your schema, name the schema in the request with the SDK's request() builder, instead of policeCertificate().

Take the schema from the verifier's policy, which lists every credential it serves once it is published:

lib/training-record-request.ts
import {
request,
type ProofRequestInput,
type ZkncMvpVerifier
} from "@human.tech/tridentgold-zknc-mvp-verifier";

// Your schema's identifier: sandbox.tridentgold.t.<tenant slug>.<name>
const TRAINING_RECORD = "sandbox.tridentgold.t.acme-training.training-record";
const DAY_MS = 86_400_000;

export async function trainingRecordRequest(verifier: ZkncMvpVerifier): Promise<ProofRequestInput> {
const policy = await verifier.getPolicy();
const entry = policy.credentials?.find((credential) => credential.schema.id === TRAINING_RECORD);
if (!entry) throw new Error("The verifier does not serve this schema yet.");

const today = new Date().toISOString().slice(0, 10);
const yearAgo = new Date(Date.now() - 365 * DAY_MS).toISOString().slice(0, 10);
return request({
purpose: "Confirm a training record completed in the last year",
schema: entry.schema, // { id, uid, version }
disclose: ["level"],
predicates: [
{ id: "completed_recently", path: ["completed_on"], op: "range", lo: yearAgo, hi: today }
]
});
}

In the request route of Request a proof, import it with import { trainingRecordRequest } from "@/lib/training-record-request"; and replace the policeCertificate({ ... }) argument:

const created = await verifier.requestProof(await trainingRecordRequest(verifier));

Remove the now unused policeCertificate import. In the result route, read this schema's values instead of the police certificate's: result.disclosed.level and result.predicates.completed_recently.

The builder puts your schema in the DCQL query's meta.schemas, and the sandbox issuer (17) in meta.issuer_ids. A verified result then carries result.disclosed.level and result.predicates.completed_recently, and its credential.schema names your schema.

What a verifier may ask about each attribute is set out in What a verifier can ask for. The policy entry lists it per attribute in entry.claims, and a request outside it throws ZkncRequestNotPermittedError. Only published schemas are listed; an approved schema that is not published yet is not.