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:
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:
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:
| Option | Default | Meaning |
|---|---|---|
baseUrl | Required. | The gateway URL: https://, with no path. |
apiKey | Required. | Your tgk_ key. A malformed key throws ZkncTenantConfigError, which never repeats the key. |
fetch | The global fetch. | A custom fetch implementation. |
timeoutMs | 15,000 | The timeout for each HTTP call. Passing { signal } to a call replaces it for that call. |
checkDraftsLocally | true | Check 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:
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:
| Field | What it is |
|---|---|
invitationId | 64 hexadecimal characters. Not secret. Use it to check the invitation's status; in your app, keep it with your own record of the invitation. |
invitationCode | The 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. |
qrSvg | A standalone SVG QR code. Today it contains the invitation code itself; treat it like the code. |
qrContent | What the QR code contains: "invitation-code" today. |
walletLinks | Always an empty array today. |
expiresAt | When 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:
- chooses Add credential;
- under Invitation code, chooses Enter code;
- 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.
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);
}
}
| State | Meaning |
|---|---|
available | Not used yet, and not expired. |
redeemed | The tester entered the code; no credential has been signed yet. |
issued | A credential was signed; its activation has not been confirmed yet. |
activated | The credential is active in the tester's app. A later revocation or supersession does not change this state. |
expired | The 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.
| Error | Codes | What to do |
|---|---|---|
ZkncSchemaInvalidError | schema_request_invalid, schema_name_invalid, schema_invalid | Fix the draft at path. For schema_invalid, findings lists every rule that failed, with its path. |
ZkncClaimsInvalidError | invitation_claim_missing, _unknown, _type, _synthetic, _too_long, _enum, _format, _ref_supplied, or a claim refusal from the sandbox issuer | Fix the value at path. |
ZkncTenantUnauthorizedError | tenant_unauthorized | Your key is malformed, unknown or revoked, or your organization's access is disabled. Ask your contact. |
ZkncTenantNotFoundError | schema_unknown, invitation_unknown | No such schema or invitation belongs to your organization. |
ZkncTenantRateLimitedError | tenant_rate_limited, schema_pending_cap, tenant_invitation_cap, invitation_credential_outstanding_rate_limited | Wait 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. |
ZkncTenantRefusedError | For 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_invalid | Read 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:
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.