Imported from C-gyeltshen/hackathon (
.agents/skills/bhutan-ndi/SKILL.md). Install upstream withnpx skills add C-gyeltshen/hackathon --skill bhutan-ndi. Copyright stays with the author.
Bhutan NDI Integration
Bhutan NDI is a self-sovereign identity (SSI) ecosystem powered by Hyperledger Indy / AnonCreds. Apps integrate via a small REST surface to do four things:
- Authenticate the calling app (OAuth2 client_credentials → JWT)
- Request a proof of one or more credential attributes — the wallet renders the request as a QR/deep-link, user approves, NDI delivers the revealed attributes asynchronously
- Issue a verifiable credential to a holder's wallet
- Receive results asynchronously via either NATS (preferred for local dev) or webhook (required when you can't keep an outbound WSS open, or for production audit trails)
The same POST /verifier/v1/proof-request powers passwordless login, age verification, and any credential check — only proofAttributes and purpose change.
When to use this skill
- Add "Sign in with NDI" / passwordless login to an app
- Verify a user is above/below 18 (or any DOB-derived predicate)
- Verify any Bhutan-issued credential attribute (ID Number, address, employer, etc.)
- Issue a verifiable credential to a Bhutan NDI Wallet holder
- Build a verifier-side back-end for NDI flows
Mental model
Three actors, three messages:
Issuer (govt agency) → Holder (citizen, wallet on phone) → Verifier (your app)
writes a VC keeps the VC asks for a proof
generates a proof from the VC checks the proof
Your app is almost always the verifier. To verify, you only need:
- Your
client_id/client_secret(gets you a JWT) - The
schema_nameof the credential you care about - A way to receive asynchronous events (NATS or webhook)
To issue, you additionally need a credDefId endorsed by the NDI Blockchain Team for your tenant.
Architecture
| Service | Host | What it does |
|---|---|---|
| Authentication | staging.bhutanndi.com |
OAuth2 client_credentials → JWT (24h) |
| Verifier | demo-client.bhutanndi.com |
Create proof requests, poll proof status |
| Issuer | demo-client.bhutanndi.com |
Create relationships, issue credentials |
| Webhook control | demo-client.bhutanndi.com/webhook |
Register/subscribe webhook URLs |
| NATS (event bus) | wss://natsdemoclient.bhutanndi.com |
Real-time async delivery of wallet events |
The biggest gotcha: auth lives on staging.bhutanndi.com, everything else on demo-client.bhutanndi.com. Hitting verifier endpoints on staging.* returns 401 Unauthorized, not 404.
Staging credentials and shared values
These are staging values — fine to embed in repo/skill. Production values are tenant-specific and must come from your NDI integration contact.
NDI_CLIENT_ID = 3tq7ho23g5risndd90a76jre5f
NDI_CLIENT_SECRET = 111rvn964mucumr6c3qq3n2poilvq5v92bkjh58p121nmoverquh
AUTH_BASE = https://staging.bhutanndi.com
API_BASE = https://demo-client.bhutanndi.com
NATS_WSS = wss://natsdemoclient.bhutanndi.com
NATS_NKEY_SEED = SUAPXY7TJFUFE3IX3OEMSLE3JFZJ3FZZRSRSOGSG2ANDIFN77O2MIBHWUM
FOUNDATIONAL_SCHEMA = https://dev-schema.ngotag.com/schemas/c7952a0a-e9b5-4a4b-a714-1e5d0a1ae076
The demo wallet (download via https://docs.bhutanndi.com/ndiwallet) ships with one preloaded user:
Full Name: Dorji Sonam Gender: Male
Date of Birth: 19/07/1995
ID Type: National ID Card ID Number: 1234
Dzongkhag: Trongsa Gewog: Tangsibjee
A list of all endorsed schemas (Foundational ID, Permanent Address, Work Permit, Student Permit, Immigration Card, Dependent Permit) lives in a public Google Sheet linked from https://docs.bhutanndi.com/schema.
The universal flow
your server NDI cloud wallet (phone)
───────────── ─────────── ───────────────
POST /authentication/v1/authenticate ──►
◄────────────── JWT (24h)
POST /verifier/v1/proof-request ──►
◄── { proofRequestThreadId,
proofRequestURL, render as QR
deepLinkURL } render as tap-link on mobile
── user scans / taps ──►
wallet generates proof
wallet POSTs to NDI cloud
NDI publishes result on
NATS subject = threadId
AND/OR webhook URL
◄═══ async delivery via NATS or webhook ═══
your server validates, parses revealed_attrs, makes a decision.
The trick is that the GET /verifier/v1/proof-request?threadId=… only tells you status — it never returns the revealed attributes. The actual proof data is delivered exclusively via NATS or webhook. Pick one (or both) and wire it up.
API tour
Bearer-token everything except /authentication/v1/authenticate.
Auth
curl -X POST 'https://staging.bhutanndi.com/authentication/v1/authenticate' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=3tq7ho23g5risndd90a76jre5f' \
--data-urlencode 'client_secret=111rvn964mucumr6c3qq3n2poilvq5v92bkjh58p121nmoverquh' \
--data-urlencode 'grant_type=client_credentials'
# → { "access_token": "eyJ…", "expires_in": 86400, "token_type": "Bearer" }
Cache the token for ~24h. Refresh ~5 min before expiry to avoid edge races.
Verifier — create a proof request
curl -X POST 'https://demo-client.bhutanndi.com/verifier/v1/proof-request' \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"proofName": "Age verification (18+)",
"proofAttributes": [
{ "name": "Date of Birth",
"restrictions": [{ "schema_name": "https://dev-schema.ngotag.com/schemas/c7952a0a-e9b5-4a4b-a714-1e5d0a1ae076" }] }
],
"purpose": "ekyc",
"authenticationLevel": "Standard",
"isShortenUrl": true
}'
# →
# {
# "statusCode": 201,
# "data": {
# "proofRequestThreadId": "50cc681c-…",
# "deepLinkURL": "bhutanndidemo://data?url=https://…",
# "proofRequestURL": "https://stage-demo-shortening-url.s3.ap-southeast-1.amazonaws.com/default/…"
# }
# }
purpose must be one of "login" | "ekyc" | "ekyc_update". Use "login" for passwordless login; "ekyc" for everything else. Both fields (purpose and authenticationLevel) are required by the staging tenant.
Encode proofRequestURL into a QR code (any QR library works, server-side or client-side). Render deepLinkURL as a button.
Issuer — create a relationship, then issue
# Step 1: create a relationship.
curl -X POST 'https://demo-client.bhutanndi.com/issuer/v1/connection' \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "label": "My App", "isShortenUrl": true }'
# → { "data": { "threadId": "…", "connectionUrl": "…", "deepLinkURL": "…" } }
# Step 2: after wallet accepts (NATS subject == threadId, payload type relationship-status/*)
# issue the credential.
curl -X POST 'https://demo-client.bhutanndi.com/issuer/v1/issue-credential' \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"credDefId": "<endorsed-credDef>",
"schemaId": "2acnD7masG23MBd7nn4xna:2:Foundational ID:1.0.0",
"credentialData": { "Full Name": "…", "Date of Birth": "01/01/2000", ... },
"forRelationship": "<relationshipDid from step 1's event>",
"threadId": "<connection threadId>",
"credentialType": "indy",
"comment": "Identity Card",
"isShortenUrl": true
}'
credDefId is not public — you have to request one from the NDI Blockchain Team for your tenant. Sample apps that demo issuance will fail at step 2 until you have one.
NATS — subscribe to results
import * as nats from "nats";
const nc = await nats.connect({
servers: ["wss://natsdemoclient.bhutanndi.com"],
authenticator: nats.nkeyAuthenticator(
new TextEncoder().encode("SUAPXY7TJFUFE3IX3OEMSLE3JFZJ3FZZRSRSOGSG2ANDIFN77O2MIBHWUM")
),
});
// NDI publishes the same proof result on FOUR subjects simultaneously:
// <bare threadId>
// VERIFIER_SERVICE/finalProofReqStatusUpdate
// VERIFIER_SERVICE/proofReqStatusUpdate
// webhook/webhook-common-pattern
// Subscribe to `>` (wildcard) and route by pattern field.
const sub = nc.subscribe(">");
for await (const msg of sub) {
const wrap = JSON.parse(msg.string()); // { pattern, data }
const inner = wrap.data || wrap; // unwrap NestJS envelope
if (inner.type === "present-proof/presentation-result") {
// inner.requested_presentation.revealed_attrs has the attrs
// inner.verification_result === "ProofValidated" iff the crypto checks out
}
}
Webhook — alternative delivery
# Register your endpoint once.
curl -X POST 'https://demo-client.bhutanndi.com/webhook/v1/register' \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"webhookId": "my-app",
"webhookURL": "https://my-public-host/ndi-webhook",
"authentication": {
"type": "OAuth2", "version": "v1",
"data": {
"url": "https://my-public-host/oauth/token",
"grant_type": "client_credentials",
"client_id": "my-app",
"client_secret": "<shared-secret>"
}
}
}'
# Then for every proof-request you make, subscribe its threadId:
curl -X POST 'https://demo-client.bhutanndi.com/webhook/v1/subscribe' \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "webhookId": "my-app", "threadId": "<proofRequestThreadId>" }'
NDI will then GET <your-oauth-url> to mint a token, then POST <your-webhook-url> with the same { pattern, data } envelope as NATS delivers. For local dev, use ngrok http <port>; the sample app auto-spawns ngrok.
The four canonical applications
Each is just a thin variant of "create proof request → receive result":
| Application | proofAttributes |
purpose |
What you do with revealed_attrs |
|---|---|---|---|
| Age verification | ["Date of Birth"] |
"ekyc" |
Parse DD/MM/YYYY → compute age → 18+ / under |
| Passwordless login | ["Full Name", "ID Number"] |
"login" |
Start a session keyed by holder_did (most stable identifier) |
| Verify credential | caller-defined | "ekyc" |
Return the attribute map; let the calling feature decide |
| Issue credential | (uses /issuer/v1/*, not proof-request) |
n/a | Step 1: create connection → wait for relationship_did; step 2: issue with credDefId |
Full payloads, responses, and code for each are in references/cookbook.md.
Common pitfalls (learned the hard way)
- 401 from verifier endpoints — verifier/issuer live on
demo-client.bhutanndi.com, notstaging.*. Only auth is onstaging.*. - "purpose must be one of …" —
purposeandauthenticationLevelare required even though swagger marks the DTO loosely. GET /verifier/v1/proof-request?threadId=…doesn't return revealed attrs — only status (proofInvitationCreated, etc.). Revealed attributes come via NATS / webhook only.bhutanndidemo://deep-link scheme — staging usesbhutanndidemo://; production usesbhutanndi://. Don't hardcode the scheme.- DOB is
DD/MM/YYYY— not ISO. Parse explicitly; mistaking it for MM/DD gives wrong ages. - NATS publishes on multiple subjects — same event hits 3-4 subjects (bare threadId,
VERIFIER_SERVICE/finalProofReqStatusUpdate,VERIFIER_SERVICE/proofReqStatusUpdate,webhook/webhook-common-pattern). Subscribe wildcard>and dedup by threadId. - NestJS envelope — NATS and webhook payloads wrap the real data:
{ pattern: "<threadId>", data: { type, requested_presentation, … } }. Always unwrapdata. revealed_attrsvalues can be arrays or objects —{value, identifier_index}or[{value, identifier_index}, …]. Normalize:Array.isArray(v) ? v[0]?.value : v?.value.- Connection-accept events have NO
holder_did— only proof-result events do. If you needholder_didfor an issue-credential call, run a proof-request after the connection. credDefIdis not in the docs — schemas are public, credDefs are tenant-specific. Email NDI to get one endorsed for your client_id.- ngrok free tier shows a browser warning page for HTML requests — JSON POSTs bypass it, so webhook delivery works. Don't be confused if
curl <ngrok>/index.htmlshows the warning.
Reference files
For the details this overview skips:
| File | What's in it |
|---|---|
references/api.md |
Every endpoint with full request/response shapes |
references/cookbook.md |
End-to-end recipes for each of the 4 applications + code |
references/credentials.md |
Staging values copy-pasted into one place |
references/transport.md |
NATS vs webhook — setup, envelope, dedup, ngrok |
references/age-verification.md |
DOB parsing + edge cases (leap days, exact-18, timezones) |
references/schema.md |
Foundational ID + other endorsed schemas + attribute names |
examples/node-client.js |
Drop-in NDIClient class — auth caching + REST wrappers |
examples/nats-subscriber.js |
NATS broker — dep-injected so it's portable |
When building a new integration, read SKILL.md (this file) + cookbook.md + the one or two references matching your goal. Don't read everything.
Authored by Sarang Parikh (sarangparikh22@gmail.com). Open an issue or email me with corrections — the NDI surface evolves and this skill needs to keep up.