Skip to content
Registry Stack Docs Latest

See it live

View as Markdown

In about ten minutes you can see the core of Registry Stack working against a public hosted lab. You will read a protected registry API from your terminal (Registry Relay, which returns records to authorized callers), then have Registry Notary issue a signed credential, delivered to a hosted demo wallet, that answers a question without exposing the record. The Relay reads are pure curl with zero install; the credential step is a guided browser flow using the lab’s hosted wallet, so it needs no install either; only the optional developer round-trip at the end also uses jq and node. Everything runs in the hosted lab at lab.registrystack.org, so there is no setup on your machine.

This lab uses synthetic data and public demo-only credentials by design. Do not reuse anything you copy here outside the lab.

The lab runs three services that you will touch directly:

For how these connect to the rest of the stack, see the architecture overview. For the full set of live demo credentials, identities, and ready-made requests, open the lab homepage at lab.registrystack.org.

If the hosted lab or one of its service URLs is unavailable, run the same multi-service topology locally with First run with Registry Lab.

This is the part you can run right now from a terminal. The civil Relay authenticates callers with Authorization: Bearer <token>. Each token carries a scope, so the same API answers some requests and refuses others.

Open lab.registrystack.org, scroll to the For developers section near the bottom, and copy two bearer tokens from the demo credential cards: a metadata-scope token and a row-reader token. Set them in your shell. The placeholders below stand in for the live tokens.

Terminal window
export CIVIL_METADATA_TOKEN="<metadata token from lab.registrystack.org>"
export CIVIL_ROW_READER_TOKEN="<row-reader token from lab.registrystack.org>"

The metadata-scope token can list the datasets the Relay publishes:

Terminal window
curl -sS \
-H "Authorization: Bearer $CIVIL_METADATA_TOKEN" \
https://civil-relay.lab.registrystack.org/v1/datasets

The Relay returns 200 OK with the catalog. Each dataset entry carries more fields than shown; they are omitted here for brevity:

{ "data": [ { "dataset_id": "civil_registry" } ] }

Reading actual records needs a token with the row scope and a Data-Purpose header that declares why you are reading:

Terminal window
curl -sS \
-H "Authorization: Bearer $CIVIL_ROW_READER_TOKEN" \
-H "Data-Purpose: https://demo.example.gov/purpose/decentralized-evidence-demo" \
"https://civil-relay.lab.registrystack.org/v1/datasets/civil_registry/entities/civil_person/records?limit=1"

The Relay returns 200 OK with one record and pagination metadata (synthetic demo data):

{
"data": [
{
"national_id": "NID-1001",
"given_name": "Miguel",
"surname": "Santos",
"birth_date": "2016-01-15",
"life_stage": "child",
"deceased": false,
"district": "north"
}
],
"pagination": { "has_more": true, "next_cursor": "..." }
}

The point of the gateway is that it never widens reach at request time. Send the metadata-scope token to the rows endpoint and the Relay refuses it:

Terminal window
curl -sS -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $CIVIL_METADATA_TOKEN" \
-H "Data-Purpose: https://demo.example.gov/purpose/decentralized-evidence-demo" \
"https://civil-relay.lab.registrystack.org/v1/datasets/civil_registry/entities/civil_person/records?limit=1"

The Relay returns 403 Forbidden with {"code":"auth.scope_denied"}. Same API, same network call, different scope, different answer. For more on this service, see Registry Relay.

Now see the issuance side. The citizen Notary issues a Selective Disclosure JWT Verifiable Credential (SD-JWT VC) of type person_is_alive_sd_jwt, media type application/dc+sd-jwt. The credential discloses a single predicate, whether the person is alive, as a true or false claim. It never hands over the underlying civil record. The Relay row-read example uses NID-1001 / Miguel Santos, while this wallet flow uses the lab wallet identity NID-2001 / Maria Santos.

Unlike the Relay reads above, this part is a guided browser flow, not a curl sequence: a real OID4VCI issuance needs a holder key and a sign-in, which is wallet territory. You do not need to install a wallet: the lab hosts a demo wallet at wallet.lab.registrystack.org, so you can receive the credential end to end in the browser.

Start in the Wallet test section of lab.registrystack.org, which links the credential flow end to end: start the citizen Notary flow, sign in, then paste the generated credential offer into the hosted wallet. The Notary requires you to sign in before it issues. The sign-in runs through eSignet, the lab’s hosted demo identity provider; you do not need an account, and the values below are synthetic demo identities:

  • National ID: NID-2001
  • Name: Maria Santos
  • Login and OTP code: 111111
  • PIN: 545411

After sign-in, the hosted wallet receives the signed person_is_alive_sd_jwt credential, with the alive predicate set to true. If you prefer your own OID4VCI-compatible wallet, you can open the credential offer directly instead:

https://citizen-notary.lab.registrystack.org/oid4vci/credential-offer?credential_configuration_id=person_is_alive_sd_jwt

Try this in the same browser flow. Self-attestation binds the credential subject to the identity you signed in as. If you sign in as NID-2001 but ask for a credential bound to NID-1001, the Notary refuses: it will not issue a credential for a subject you did not authenticate as. The issuer cannot be talked into vouching for someone else. For more on this service, see Registry Notary.

For a pure API credential flow, use the hosted DHIS2 Notary: the lab runs a demo DHIS2 health information system with a Notary in front of it, so the same issuance contract can be exercised against a second, independent source system. This path does not use the browser wallet sign-in, and unlike the curl-only reads above it also needs jq and node on your machine. It evaluates DHIS2 child-program claims, issues an application/dc+sd-jwt credential, fetches the issuer JWKS, verifies the Ed25519 signature, and checks that each disclosure hash is listed in the issuer-signed JWT.

Set the hosted Notary URL and load the current demo bearer token from the lab JSON. (lab.json carries a credentials array whose entries have an id and a token, plus the service list; the dhis2-bearer entry is the demo evidence-client token for this Notary.)

Terminal window
export DHIS2_NOTARY_URL="https://dhis2-notary.lab.registrystack.org"
export DHIS2_PURPOSE="https://demo.example.gov/purpose/dhis2-openfn-health-evidence"
export DHIS2_EVIDENCE_CLIENT_BEARER="$(
curl -fsS https://lab.registrystack.org/api/lab.json |
jq -r '.credentials[] | select(.id == "dhis2-bearer") | .token'
)"

If you already have registryctl, you can load the same values from the hosted lab manifest through the helper:

Terminal window
eval "$(
registryctl lab env --credential dhis2-bearer --format json |
jq -er '
if ([.base_url, .purpose, .token] | all(type == "string" and length > 0)) then
@sh "DHIS2_NOTARY_URL=\(.base_url) DHIS2_PURPOSE=\(.purpose) DHIS2_EVIDENCE_CLIENT_BEARER=\(.token)"
else
error("registryctl lab env output is missing a required field")
end
'
)"
export DHIS2_NOTARY_URL DHIS2_PURPOSE DHIS2_EVIDENCE_CLIENT_BEARER

Evaluate the credential claims for the live demo tracked entity:

Terminal window
cat > evaluation-request.json <<'JSON'
{
"target": {
"type": "TrackedEntity",
"identifiers": [
{ "scheme": "dhis2_tracked_entity", "value": "PQfMcpmXeFE" }
]
},
"claims": [
"dhis2-tracked-entity-first-name",
"dhis2-tracked-entity-last-name",
"dhis2-child-program-active"
],
"disclosure": "value",
"format": "application/dc+sd-jwt"
}
JSON
curl -fsS -X POST "$DHIS2_NOTARY_URL/v1/evaluations" \
-H "Authorization: Bearer $DHIS2_EVIDENCE_CLIENT_BEARER" \
-H "Content-Type: application/json" \
-H "Data-Purpose: $DHIS2_PURPOSE" \
--data @evaluation-request.json \
-o evaluation.json
jq '.results[] | {claim_id, value, evaluation_id, format}' evaluation.json

Issue the SD-JWT VC from that evaluation:

Terminal window
EVALUATION_ID="$(jq -r '.results[0].evaluation_id' evaluation.json)"
jq -n --arg evaluation_id "$EVALUATION_ID" '{
evaluation_id: $evaluation_id,
credential_profile: "dhis2_child_program_sd_jwt",
format: "application/dc+sd-jwt",
claims: [
"dhis2-tracked-entity-first-name",
"dhis2-tracked-entity-last-name",
"dhis2-child-program-active"
],
disclosure: "value"
}' > credential-request.json
curl -fsS -X POST "$DHIS2_NOTARY_URL/v1/credentials" \
-H "Authorization: Bearer $DHIS2_EVIDENCE_CLIENT_BEARER" \
-H "Content-Type: application/json" \
--data @credential-request.json \
-o credential.json
jq '{credential_id, credential_profile, issuer, format, disclosure_count: (.disclosures | length)}' credential.json

Fetch the issuer keys and verify the credential. The JWKS endpoint is public: any verifier can fetch the issuer keys without a credential.

Terminal window
curl -fsS "$DHIS2_NOTARY_URL/.well-known/evidence/jwks.json" -o jwks.json
node <<'NODE'
const { createHash, createPublicKey, verify } = require('node:crypto');
const { readFileSync } = require('node:fs');
const credential = JSON.parse(readFileSync('credential.json', 'utf8'));
const jwks = JSON.parse(readFileSync('jwks.json', 'utf8'));
const [header64, payload64, signature64] = credential.issuer_signed_jwt.split('.');
const header = JSON.parse(Buffer.from(header64, 'base64url').toString('utf8'));
const payload = JSON.parse(Buffer.from(payload64, 'base64url').toString('utf8'));
const keys = Array.isArray(jwks.keys) ? jwks.keys : [];
const key = keys.find((candidate) => candidate.kid === header.kid);
if (!key) throw new Error(`Missing JWKS key ${header.kid}`);
const signed = Buffer.from(`${header64}.${payload64}`);
const signature = Buffer.from(signature64, 'base64url');
const signatureValid = verify(null, signed, createPublicKey({ key, format: 'jwk' }), signature);
if (!signatureValid) throw new Error('Bad issuer signature');
const disclosureDigests = new Set(payload._sd || []);
const disclosureClaims = [];
for (const disclosure of credential.disclosures || []) {
const digest = createHash('sha256').update(disclosure).digest('base64url');
if (!disclosureDigests.has(digest)) {
throw new Error(`Disclosure digest not present in _sd: ${digest}`);
}
const decoded = JSON.parse(Buffer.from(disclosure, 'base64url').toString('utf8'));
disclosureClaims.push(decoded[1]);
}
console.log(JSON.stringify({
issuer: payload.iss,
vct: payload.vct,
signature_valid: signatureValid,
disclosure_count: disclosureClaims.length,
disclosure_claims: disclosureClaims.sort()
}, null, 2));
NODE

Expected verification summary:

{
"issuer": "did:web:dhis2-notary.lab.registrystack.org",
"vct": "https://dhis2-notary.lab.registrystack.org/credentials/dhis2/child-program/v1",
"signature_valid": true,
"disclosure_count": 3,
"disclosure_claims": [
"dhis2-child-program-active",
"dhis2-tracked-entity-first-name",
"dhis2-tracked-entity-last-name"
]
}

You have seen the two payoffs against the hosted lab. To run the same shapes on your own machine, start with the local single-node tutorials:

Only assessing fit? You do not need to install anything: When to use Registry Stack covers fit and non-goals, and DPI safeguards alignment maps the stack to safeguards language for review programs.