What this is (read this first)
Hyperledger Identus is a system that gives every CryptoBazaar user a digital identity (called a DID) and issues them tamper-proof certificates (called Verifiable Credentials, or VCs) every time they pass a verification step. Think of it like a passport stamp — but one that is mathematically impossible to forge, lives on a blockchain, and belongs to the user, not to CryptoBazaar.
Why we need it:
- Instead of CryptoBazaar storing your Aadhaar/PAN data, it issues you a VC that says "this person passed KYC on this date" — signed with our private key. We never store the raw data.
- If a user's bank is frozen, they can prove they were a verified legitimate trader at the time using their DID credentials — without needing CryptoBazaar to produce records.
- It's structural DPDPA compliance: the personal data is processed in-flight and discarded. Only the DID lives in the DB.
The 3 credentials this integration issues:
| Credential |
Issued when |
DB field |
KYCCredential |
Didit KYC webhook returns Approved |
User.kycCredentialId |
EDDCredential |
Bank statement uploaded and accepted |
User.eddCredentialId |
InterviewCredential |
AI questionnaire submitted and accepted |
User.interviewCredId |
Codebase map — what already exists
Before you write a single line, read these files:
frontend/src/lib/identus.ts — stub file with createDIDForUser() already written. Needs to be extended with VC issuance, VC verification, and VC revocation functions.
frontend/prisma/schema.prisma — the User model already has these fields (no migration needed):
did String? @unique — stores the user's DID
kycCredentialId String? — stores the Identus credential record ID for KYC
eddCredentialId String? — stores the Identus credential record ID for EDD
interviewCredId String? — stores the Identus credential record ID for interview
frontend/src/app/api/verification/link-wallet/route.ts — this is where DID creation should be triggered (wallet link = first permanent identity action)
frontend/src/app/api/webhooks/didit/route.ts — KYC VC issuance should be called here after passed === true
frontend/src/app/api/verification/analyze-statement/route.ts — EDD VC issuance should be called here after bank statement is accepted
frontend/src/app/api/verification/submit-questionnaire/route.ts — Interview VC issuance should be called here after questionnaire is submitted
frontend/src/app/api/orders/[id]/route.ts — VC verification should be checked here before the lock action (buyer locks an order = starts a trade)
Part 0 — Set up Identus Cloud Agent locally
Identus runs as a separate service (a Cloud Agent) that your Next.js app talks to over HTTP. You need to run it locally for development.
Step 1 — Install Docker
If you don't have Docker: https://docs.docker.com/get-docker/
Step 2 — Run the Identus Cloud Agent via Docker Compose
Create a file called identus-docker-compose.yml anywhere on your machine (do NOT put it inside the frontend/ folder):
```yaml
version: "3.8"
services:
db:
image: postgres:15
environment:
POSTGRES_DB: identus
POSTGRES_USER: identus
POSTGRES_PASSWORD: identus
ports:
- "5433:5432"
prism-node:
image: ghcr.io/input-output-hk/prism-node:2.3.0
environment:
NODE_PSQL_HOST: db
NODE_PSQL_DATABASE: identus
NODE_PSQL_USERNAME: identus
NODE_PSQL_PASSWORD: identus
depends_on:
- db
cloud-agent:
image: ghcr.io/hyperledger/identus-cloud-agent:1.38.0
environment:
POLLUX_DB_HOST: db
POLLUX_DB_PORT: 5432
POLLUX_DB_NAME: identus
POLLUX_DB_USER: identus
POLLUX_DB_PASSWORD: identus
CONNECT_DB_HOST: db
CONNECT_DB_PORT: 5432
CONNECT_DB_NAME: identus
CONNECT_DB_USER: identus
CONNECT_DB_PASSWORD: identus
AGENT_DB_HOST: db
AGENT_DB_PORT: 5432
AGENT_DB_NAME: identus
AGENT_DB_USER: identus
AGENT_DB_PASSWORD: identus
PRISM_NODE_HOST: prism-node
PRISM_NODE_PORT: 50053
API_KEY_ENABLED: "true"
DEFAULT_WALLET_ENABLED: "true"
DEFAULT_WALLET_AUTH_API_KEY: "my-local-api-key"
SECRET_STORAGE_BACKEND: vault
ports:
- "8080:8080"
depends_on:
- db
- prism-node
```
Run it:
```bash
docker-compose -f identus-docker-compose.yml up -d
```
Wait ~30 seconds, then check it's running:
```bash
curl http://localhost:8080/cloud-agent/v1/_health
Should return: {"status":"ok"}
```
Step 3 — Add env vars to frontend/.env.local
```
IDENTUS_AGENT_URL=http://localhost:8080
IDENTUS_API_KEY=my-local-api-key
```
Step 4 — Create CryptoBazaar's issuer DID (one-time setup)
CryptoBazaar itself needs a DID so it can sign credentials. Run this once after the agent starts:
```bash
curl -X POST http://localhost:8080/cloud-agent/v1/did-registrar/dids
-H "Content-Type: application/json"
-H "apikey: my-local-api-key"
-d '{
"documentTemplate": {
"publicKeys": [
{ "id": "auth-key", "purpose": "authentication" },
{ "id": "issue-key", "purpose": "assertionMethod" }
],
"services": []
}
}'
```
Copy the longFormDid from the response and add it to .env.local:
```
IDENTUS_ISSUER_DID=did:prism:abc123... (paste your longFormDid here)
```
Important: In production, this issuer DID is created once on the production Cloud Agent and stored permanently. Do not recreate it — all existing credentials would become unverifiable.
Part 1 — Extend frontend/src/lib/identus.ts
The file currently only has createDIDForUser(). Add these four functions:
1a — issueVerifiableCredential()
```ts
export async function issueVerifiableCredential(
subjectDid: string,
credentialType: "KYCCredential" | "EDDCredential" | "InterviewCredential",
validityDays: number = 180
): Promise {
// Returns the Identus credential record ID (store this in DB as kycCredentialId etc.)
if (!process.env.IDENTUS_AGENT_URL || !process.env.IDENTUS_ISSUER_DID) {
// Stub mode — return a fake ID for local dev without the agent
return stub-${credentialType}-${Date.now()}
}
const issuedAt = new Date().toISOString()
const validUntil = new Date(Date.now() + validityDays * 86400000).toISOString()
const response = await fetch(
${IDENTUS_AGENT_URL}/cloud-agent/v1/issue-credentials/credential-offers,
{
method: "POST",
headers: { "Content-Type": "application/json", apikey: IDENTUS_API_KEY },
body: JSON.stringify({
credentialFormat: "JWT",
claims: {
type: credentialType,
platform: "CryptoBazaar",
issuedAt,
validUntil,
},
issuingDID: process.env.IDENTUS_ISSUER_DID,
subjectId: subjectDid,
automaticIssuance: true,
}),
}
)
if (!response.ok) {
throw new Error(Identus VC issuance failed: ${response.status})
}
const data = await response.json()
return data.recordId as string // this is what you store in DB
}
```
1b — verifyUserCredentials()
```ts
export async function verifyUserCredentials(userDid: string): Promise<{
kycValid: boolean
eddValid: boolean
interviewValid: boolean
allValid: boolean
}> {
// Stub mode — if no agent configured, assume valid (don't block trades in dev)
if (!process.env.IDENTUS_AGENT_URL || !process.env.IDENTUS_ISSUER_DID) {
return { kycValid: true, eddValid: true, interviewValid: true, allValid: true }
}
// Fetch all credentials for this DID from the agent
const response = await fetch(
${IDENTUS_AGENT_URL}/cloud-agent/v1/issue-credentials/records?subjectId=${encodeURIComponent(userDid)},
{ headers: { apikey: IDENTUS_API_KEY } }
)
if (!response.ok) return { kycValid: false, eddValid: false, interviewValid: false, allValid: false }
const data = await response.json()
const records: Array<{ claims: { type: string; validUntil: string }; protocolState: string }> =
data.contents ?? []
const now = new Date()
const isValid = (type: string) =>
records.some(
(r) =>
r.claims?.type === type &&
r.protocolState === "CredentialSent" &&
new Date(r.claims.validUntil) > now
)
const kycValid = isValid("KYCCredential")
const eddValid = isValid("EDDCredential")
const interviewValid = isValid("InterviewCredential")
return { kycValid, eddValid, interviewValid, allValid: kycValid && eddValid && interviewValid }
}
```
1c — revokeCredential()
```ts
export async function revokeCredential(recordId: string): Promise {
if (!process.env.IDENTUS_AGENT_URL) return // stub mode
await fetch(
${IDENTUS_AGENT_URL}/cloud-agent/v1/issue-credentials/records/${recordId}/problem-report,
{
method: "POST",
headers: { "Content-Type": "application/json", apikey: IDENTUS_API_KEY },
body: JSON.stringify({ code: "credential.revoked" }),
}
)
}
```
Part 2 — Trigger DID creation on wallet link
File: frontend/src/app/api/verification/link-wallet/route.ts
After the db.user.update(...) call that saves the wallet address (around line 32), add:
```ts
import { createDIDForUser } from "@/lib/identus"
// After db.user.update(...)
// Create a DID for the user if they don't have one yet
if (!user.did) {
try {
const did = await createDIDForUser(user.id)
await db.user.update({
where: { id: user.id },
data: { did },
})
} catch (err) {
// DID creation failing should not block wallet linking
// Log it but let the user proceed
console.error("[link-wallet] DID creation failed:", err)
}
}
```
Why wallet link? The wallet is the user's permanent on-chain identity anchor. Creating the DID here ties the DID lifecycle to the wallet — if the wallet changes, the user restarts verification and gets a new DID.
Part 3 — Issue KYC credential in the Didit webhook
File: frontend/src/app/api/webhooks/didit/route.ts
After the db.user.update(...) at line ~87 that sets kycVerifiedAt, add:
```ts
import { issueVerifiableCredential } from "@/lib/identus"
// After db.user.update({ kycVerifiedAt, status: "WALLET_PENDING" })
// Issue KYC credential to the user's DID
try {
const updatedUser = await db.user.findUnique({ where: { id: userId }, select: { did: true } })
if (updatedUser?.did) {
const recordId = await issueVerifiableCredential(updatedUser.did, "KYCCredential")
await db.user.update({
where: { id: userId },
data: { kycCredentialId: recordId },
})
}
} catch (err) {
console.error("[webhook/didit] KYC VC issuance failed:", err)
// Don't fail the webhook — KYC is still marked as passed
}
```
Part 4 — Issue EDD credential after bank statement
File: frontend/src/app/api/verification/analyze-statement/route.ts
After the db.user.update(...) that sets status to "ONBOARDING_PENDING" (around line 261), add:
```ts
import { issueVerifiableCredential } from "@/lib/identus"
// After db.user.update(...)
try {
const freshUser = await db.user.findUnique({ where: { id: user.id }, select: { did: true } })
if (freshUser?.did) {
const recordId = await issueVerifiableCredential(freshUser.did, "EDDCredential")
await db.user.update({
where: { id: user.id },
data: { eddCredentialId: recordId },
})
}
} catch (err) {
console.error("[analyze-statement] EDD VC issuance failed:", err)
}
```
Part 5 — Issue Interview credential after questionnaire
File: frontend/src/app/api/verification/submit-questionnaire/route.ts
After the db.user.update(...) that sets status to "ONBOARDING_PENDING" (around line 107), add:
```ts
import { issueVerifiableCredential } from "@/lib/identus"
// After db.user.update(...)
try {
const freshUser = await db.user.findUnique({ where: { id: user.id }, select: { did: true } })
if (freshUser?.did) {
const recordId = await issueVerifiableCredential(freshUser.did, "InterviewCredential")
await db.user.update({
where: { id: user.id },
data: { interviewCredId: recordId },
})
}
} catch (err) {
console.error("[submit-questionnaire] Interview VC issuance failed:", err)
}
```
Part 6 — Verify credentials before a trade starts
File: frontend/src/app/api/orders/[id]/route.ts
In the PATCH handler, inside the case "lock": block (around line 96), add a credential check before the db.order.update(...):
```ts
import { verifyUserCredentials } from "@/lib/identus"
case "lock": {
if (isSeller)
return NextResponse.json({ error: "Seller cannot lock own order" }, { status: 403 })
if (order.status !== "LISTED")
return NextResponse.json({ error: "Order not available" }, { status: 400 })
if (user.status !== "VERIFIED")
return NextResponse.json({ error: "Must be verified to buy" }, { status: 403 })
// NEW: verify the buyer's VCs are still valid and unexpired
if (user.did) {
const creds = await verifyUserCredentials(user.did)
if (!creds.allValid) {
return NextResponse.json(
{
error: "Your verification credentials have expired. Please complete re-verification before trading.",
credentialStatus: creds,
},
{ status: 403 }
)
}
}
// existing db.order.update(...) continues here
```
Also verify the seller's credentials when the buyer locks (the seller should also be currently verified):
```ts
// Also verify seller's credentials
const sellerFull = await db.user.findUnique({ where: { id: order.sellerId }, select: { did: true } })
if (sellerFull?.did) {
const sellerCreds = await verifyUserCredentials(sellerFull.did)
if (!sellerCreds.allValid) {
return NextResponse.json(
{ error: "Seller's verification has expired. This order cannot be locked." },
{ status: 403 }
)
}
}
```
Part 7 — Environment variables
Local development (frontend/.env.local)
```
IDENTUS_AGENT_URL=http://localhost:8080
IDENTUS_API_KEY=my-local-api-key
IDENTUS_ISSUER_DID=did:prism:YOUR_ISSUER_DID_HERE
```
Vercel (production)
Add the same three variables in the Vercel dashboard under Settings → Environment Variables:
IDENTUS_AGENT_URL — URL of your production Cloud Agent (could be self-hosted on a VPS or a managed Identus instance)
IDENTUS_API_KEY — the API key for your production agent
IDENTUS_ISSUER_DID — the production issuer DID (created once, never recreated)
Stub mode: If IDENTUS_AGENT_URL is not set, all Identus calls fall back to stub behaviour (fake IDs returned, verification always passes). This means the app works fully without Identus configured — useful for development and staging environments where you don't want to run the full agent stack.
Part 8 — How to test it
Test 1 — DID creation
- Start the Cloud Agent:
docker-compose -f identus-docker-compose.yml up -d
- Set
IDENTUS_AGENT_URL=http://localhost:8080 and IDENTUS_API_KEY=my-local-api-key in .env.local
- Go through the onboarding flow → connect a wallet
- After connecting, check the DB:
SELECT did FROM users WHERE clerk_id = 'your_clerk_id';
- Expected: a
did:prism:... string is now stored
Test 2 — VC issuance
- Complete bank statement upload (or use the
[DEV] Skip button in development)
- Check the DB:
SELECT edd_credential_id FROM users WHERE clerk_id = 'your_clerk_id';
- Expected: a non-null
recordId string from Identus
- Also verify via the Cloud Agent directly:
```bash
curl http://localhost:8080/cloud-agent/v1/issue-credentials/records
-H "apikey: my-local-api-key"
```
You should see a record with protocolState: "CredentialSent" for each step completed.
Test 3 — VC verification at trade time
- Complete full verification for two test accounts (buyer and seller)
- Post a sell order from the seller account
- Try to lock it from the buyer account
- Expected: order locks successfully
- Now manually set
eddCredentialId = null in the DB for the buyer and retry
- Expected: API returns 403 with "Your verification credentials have expired"
Test 4 — Stub mode (no agent)
- Remove
IDENTUS_AGENT_URL from .env.local
- Go through the whole flow
- Expected: everything works,
did field in DB gets a did:prism:stub-... value, trades work normally
- This confirms that Identus is non-blocking — its failure should never stop the core trade flow
Acceptance criteria
Stack context
- Framework: Next.js 15 App Router, TypeScript
- DB: Prisma + PostgreSQL. Fields
User.did, User.kycCredentialId, User.eddCredentialId, User.interviewCredId already exist — no migration needed
- Identus SDK: The integration uses direct REST API calls to the Cloud Agent (
fetch), not the npm SDK. This keeps the dependency surface small and avoids SDK version conflicts.
- DID method:
did:prism (Cardano-anchored, managed by the Cloud Agent)
- Credential format: JWT VCs (W3C standard)
- Existing stub:
frontend/src/lib/identus.ts already has createDIDForUser() with stub fallback — extend this file, don't create a new one
- Non-blocking design: Every Identus call must be wrapped in try/catch. A dead Cloud Agent should log an error but never prevent a user from completing onboarding or trading
What this is (read this first)
Hyperledger Identus is a system that gives every CryptoBazaar user a digital identity (called a DID) and issues them tamper-proof certificates (called Verifiable Credentials, or VCs) every time they pass a verification step. Think of it like a passport stamp — but one that is mathematically impossible to forge, lives on a blockchain, and belongs to the user, not to CryptoBazaar.
Why we need it:
The 3 credentials this integration issues:
KYCCredentialUser.kycCredentialIdEDDCredentialUser.eddCredentialIdInterviewCredentialUser.interviewCredIdCodebase map — what already exists
Before you write a single line, read these files:
frontend/src/lib/identus.ts— stub file withcreateDIDForUser()already written. Needs to be extended with VC issuance, VC verification, and VC revocation functions.frontend/prisma/schema.prisma— theUsermodel already has these fields (no migration needed):did String? @unique— stores the user's DIDkycCredentialId String?— stores the Identus credential record ID for KYCeddCredentialId String?— stores the Identus credential record ID for EDDinterviewCredId String?— stores the Identus credential record ID for interviewfrontend/src/app/api/verification/link-wallet/route.ts— this is where DID creation should be triggered (wallet link = first permanent identity action)frontend/src/app/api/webhooks/didit/route.ts— KYC VC issuance should be called here afterpassed === truefrontend/src/app/api/verification/analyze-statement/route.ts— EDD VC issuance should be called here after bank statement is acceptedfrontend/src/app/api/verification/submit-questionnaire/route.ts— Interview VC issuance should be called here after questionnaire is submittedfrontend/src/app/api/orders/[id]/route.ts— VC verification should be checked here before thelockaction (buyer locks an order = starts a trade)Part 0 — Set up Identus Cloud Agent locally
Identus runs as a separate service (a Cloud Agent) that your Next.js app talks to over HTTP. You need to run it locally for development.
Step 1 — Install Docker
If you don't have Docker: https://docs.docker.com/get-docker/
Step 2 — Run the Identus Cloud Agent via Docker Compose
Create a file called
identus-docker-compose.ymlanywhere on your machine (do NOT put it inside thefrontend/folder):```yaml
version: "3.8"
services:
db:
image: postgres:15
environment:
POSTGRES_DB: identus
POSTGRES_USER: identus
POSTGRES_PASSWORD: identus
ports:
- "5433:5432"
prism-node:
image: ghcr.io/input-output-hk/prism-node:2.3.0
environment:
NODE_PSQL_HOST: db
NODE_PSQL_DATABASE: identus
NODE_PSQL_USERNAME: identus
NODE_PSQL_PASSWORD: identus
depends_on:
- db
cloud-agent:
image: ghcr.io/hyperledger/identus-cloud-agent:1.38.0
environment:
POLLUX_DB_HOST: db
POLLUX_DB_PORT: 5432
POLLUX_DB_NAME: identus
POLLUX_DB_USER: identus
POLLUX_DB_PASSWORD: identus
CONNECT_DB_HOST: db
CONNECT_DB_PORT: 5432
CONNECT_DB_NAME: identus
CONNECT_DB_USER: identus
CONNECT_DB_PASSWORD: identus
AGENT_DB_HOST: db
AGENT_DB_PORT: 5432
AGENT_DB_NAME: identus
AGENT_DB_USER: identus
AGENT_DB_PASSWORD: identus
PRISM_NODE_HOST: prism-node
PRISM_NODE_PORT: 50053
API_KEY_ENABLED: "true"
DEFAULT_WALLET_ENABLED: "true"
DEFAULT_WALLET_AUTH_API_KEY: "my-local-api-key"
SECRET_STORAGE_BACKEND: vault
ports:
- "8080:8080"
depends_on:
- db
- prism-node
```
Run it:
```bash
docker-compose -f identus-docker-compose.yml up -d
```
Wait ~30 seconds, then check it's running:
```bash
curl http://localhost:8080/cloud-agent/v1/_health
Should return: {"status":"ok"}
```
Step 3 — Add env vars to
frontend/.env.local```
IDENTUS_AGENT_URL=http://localhost:8080
IDENTUS_API_KEY=my-local-api-key
```
Step 4 — Create CryptoBazaar's issuer DID (one-time setup)
CryptoBazaar itself needs a DID so it can sign credentials. Run this once after the agent starts:
```bash
curl -X POST http://localhost:8080/cloud-agent/v1/did-registrar/dids
-H "Content-Type: application/json"
-H "apikey: my-local-api-key"
-d '{
"documentTemplate": {
"publicKeys": [
{ "id": "auth-key", "purpose": "authentication" },
{ "id": "issue-key", "purpose": "assertionMethod" }
],
"services": []
}
}'
```
Copy the
longFormDidfrom the response and add it to.env.local:```
IDENTUS_ISSUER_DID=did:prism:abc123... (paste your longFormDid here)
```
Important: In production, this issuer DID is created once on the production Cloud Agent and stored permanently. Do not recreate it — all existing credentials would become unverifiable.
Part 1 — Extend
frontend/src/lib/identus.tsThe file currently only has
createDIDForUser(). Add these four functions:1a —
issueVerifiableCredential()```ts
export async function issueVerifiableCredential(
subjectDid: string,
credentialType: "KYCCredential" | "EDDCredential" | "InterviewCredential",
validityDays: number = 180
): Promise {
// Returns the Identus credential record ID (store this in DB as kycCredentialId etc.)
if (!process.env.IDENTUS_AGENT_URL || !process.env.IDENTUS_ISSUER_DID) {
// Stub mode — return a fake ID for local dev without the agent
return
stub-${credentialType}-${Date.now()}}
const issuedAt = new Date().toISOString()
const validUntil = new Date(Date.now() + validityDays * 86400000).toISOString()
const response = await fetch(
${IDENTUS_AGENT_URL}/cloud-agent/v1/issue-credentials/credential-offers,{
method: "POST",
headers: { "Content-Type": "application/json", apikey: IDENTUS_API_KEY },
body: JSON.stringify({
credentialFormat: "JWT",
claims: {
type: credentialType,
platform: "CryptoBazaar",
issuedAt,
validUntil,
},
issuingDID: process.env.IDENTUS_ISSUER_DID,
subjectId: subjectDid,
automaticIssuance: true,
}),
}
)
if (!response.ok) {
throw new Error(
Identus VC issuance failed: ${response.status})}
const data = await response.json()
return data.recordId as string // this is what you store in DB
}
```
1b —
verifyUserCredentials()```ts
export async function verifyUserCredentials(userDid: string): Promise<{
kycValid: boolean
eddValid: boolean
interviewValid: boolean
allValid: boolean
}> {
// Stub mode — if no agent configured, assume valid (don't block trades in dev)
if (!process.env.IDENTUS_AGENT_URL || !process.env.IDENTUS_ISSUER_DID) {
return { kycValid: true, eddValid: true, interviewValid: true, allValid: true }
}
// Fetch all credentials for this DID from the agent
const response = await fetch(
${IDENTUS_AGENT_URL}/cloud-agent/v1/issue-credentials/records?subjectId=${encodeURIComponent(userDid)},{ headers: { apikey: IDENTUS_API_KEY } }
)
if (!response.ok) return { kycValid: false, eddValid: false, interviewValid: false, allValid: false }
const data = await response.json()
const records: Array<{ claims: { type: string; validUntil: string }; protocolState: string }> =
data.contents ?? []
const now = new Date()
const isValid = (type: string) =>
records.some(
(r) =>
r.claims?.type === type &&
r.protocolState === "CredentialSent" &&
new Date(r.claims.validUntil) > now
)
const kycValid = isValid("KYCCredential")
const eddValid = isValid("EDDCredential")
const interviewValid = isValid("InterviewCredential")
return { kycValid, eddValid, interviewValid, allValid: kycValid && eddValid && interviewValid }
}
```
1c —
revokeCredential()```ts
export async function revokeCredential(recordId: string): Promise {
if (!process.env.IDENTUS_AGENT_URL) return // stub mode
await fetch(
${IDENTUS_AGENT_URL}/cloud-agent/v1/issue-credentials/records/${recordId}/problem-report,{
method: "POST",
headers: { "Content-Type": "application/json", apikey: IDENTUS_API_KEY },
body: JSON.stringify({ code: "credential.revoked" }),
}
)
}
```
Part 2 — Trigger DID creation on wallet link
File:
frontend/src/app/api/verification/link-wallet/route.tsAfter the
db.user.update(...)call that saves the wallet address (around line 32), add:```ts
import { createDIDForUser } from "@/lib/identus"
// After db.user.update(...)
// Create a DID for the user if they don't have one yet
if (!user.did) {
try {
const did = await createDIDForUser(user.id)
await db.user.update({
where: { id: user.id },
data: { did },
})
} catch (err) {
// DID creation failing should not block wallet linking
// Log it but let the user proceed
console.error("[link-wallet] DID creation failed:", err)
}
}
```
Why wallet link? The wallet is the user's permanent on-chain identity anchor. Creating the DID here ties the DID lifecycle to the wallet — if the wallet changes, the user restarts verification and gets a new DID.
Part 3 — Issue KYC credential in the Didit webhook
File:
frontend/src/app/api/webhooks/didit/route.tsAfter the
db.user.update(...)at line ~87 that setskycVerifiedAt, add:```ts
import { issueVerifiableCredential } from "@/lib/identus"
// After db.user.update({ kycVerifiedAt, status: "WALLET_PENDING" })
// Issue KYC credential to the user's DID
try {
const updatedUser = await db.user.findUnique({ where: { id: userId }, select: { did: true } })
if (updatedUser?.did) {
const recordId = await issueVerifiableCredential(updatedUser.did, "KYCCredential")
await db.user.update({
where: { id: userId },
data: { kycCredentialId: recordId },
})
}
} catch (err) {
console.error("[webhook/didit] KYC VC issuance failed:", err)
// Don't fail the webhook — KYC is still marked as passed
}
```
Part 4 — Issue EDD credential after bank statement
File:
frontend/src/app/api/verification/analyze-statement/route.tsAfter the
db.user.update(...)that sets status to"ONBOARDING_PENDING"(around line 261), add:```ts
import { issueVerifiableCredential } from "@/lib/identus"
// After db.user.update(...)
try {
const freshUser = await db.user.findUnique({ where: { id: user.id }, select: { did: true } })
if (freshUser?.did) {
const recordId = await issueVerifiableCredential(freshUser.did, "EDDCredential")
await db.user.update({
where: { id: user.id },
data: { eddCredentialId: recordId },
})
}
} catch (err) {
console.error("[analyze-statement] EDD VC issuance failed:", err)
}
```
Part 5 — Issue Interview credential after questionnaire
File:
frontend/src/app/api/verification/submit-questionnaire/route.tsAfter the
db.user.update(...)that sets status to"ONBOARDING_PENDING"(around line 107), add:```ts
import { issueVerifiableCredential } from "@/lib/identus"
// After db.user.update(...)
try {
const freshUser = await db.user.findUnique({ where: { id: user.id }, select: { did: true } })
if (freshUser?.did) {
const recordId = await issueVerifiableCredential(freshUser.did, "InterviewCredential")
await db.user.update({
where: { id: user.id },
data: { interviewCredId: recordId },
})
}
} catch (err) {
console.error("[submit-questionnaire] Interview VC issuance failed:", err)
}
```
Part 6 — Verify credentials before a trade starts
File:
frontend/src/app/api/orders/[id]/route.tsIn the PATCH handler, inside the
case "lock":block (around line 96), add a credential check before thedb.order.update(...):```ts
import { verifyUserCredentials } from "@/lib/identus"
case "lock": {
if (isSeller)
return NextResponse.json({ error: "Seller cannot lock own order" }, { status: 403 })
if (order.status !== "LISTED")
return NextResponse.json({ error: "Order not available" }, { status: 400 })
if (user.status !== "VERIFIED")
return NextResponse.json({ error: "Must be verified to buy" }, { status: 403 })
// NEW: verify the buyer's VCs are still valid and unexpired
if (user.did) {
const creds = await verifyUserCredentials(user.did)
if (!creds.allValid) {
return NextResponse.json(
{
error: "Your verification credentials have expired. Please complete re-verification before trading.",
credentialStatus: creds,
},
{ status: 403 }
)
}
}
// existing db.order.update(...) continues here
```
Also verify the seller's credentials when the buyer locks (the seller should also be currently verified):
```ts
// Also verify seller's credentials
const sellerFull = await db.user.findUnique({ where: { id: order.sellerId }, select: { did: true } })
if (sellerFull?.did) {
const sellerCreds = await verifyUserCredentials(sellerFull.did)
if (!sellerCreds.allValid) {
return NextResponse.json(
{ error: "Seller's verification has expired. This order cannot be locked." },
{ status: 403 }
)
}
}
```
Part 7 — Environment variables
Local development (
frontend/.env.local)```
IDENTUS_AGENT_URL=http://localhost:8080
IDENTUS_API_KEY=my-local-api-key
IDENTUS_ISSUER_DID=did:prism:YOUR_ISSUER_DID_HERE
```
Vercel (production)
Add the same three variables in the Vercel dashboard under Settings → Environment Variables:
IDENTUS_AGENT_URL— URL of your production Cloud Agent (could be self-hosted on a VPS or a managed Identus instance)IDENTUS_API_KEY— the API key for your production agentIDENTUS_ISSUER_DID— the production issuer DID (created once, never recreated)Stub mode: If
IDENTUS_AGENT_URLis not set, all Identus calls fall back to stub behaviour (fake IDs returned, verification always passes). This means the app works fully without Identus configured — useful for development and staging environments where you don't want to run the full agent stack.Part 8 — How to test it
Test 1 — DID creation
docker-compose -f identus-docker-compose.yml up -dIDENTUS_AGENT_URL=http://localhost:8080andIDENTUS_API_KEY=my-local-api-keyin.env.localSELECT did FROM users WHERE clerk_id = 'your_clerk_id';did:prism:...string is now storedTest 2 — VC issuance
[DEV] Skipbutton in development)SELECT edd_credential_id FROM users WHERE clerk_id = 'your_clerk_id';recordIdstring from Identus```bash
curl http://localhost:8080/cloud-agent/v1/issue-credentials/records
-H "apikey: my-local-api-key"
```
You should see a record with
protocolState: "CredentialSent"for each step completed.Test 3 — VC verification at trade time
eddCredentialId = nullin the DB for the buyer and retryTest 4 — Stub mode (no agent)
IDENTUS_AGENT_URLfrom.env.localdidfield in DB gets adid:prism:stub-...value, trades work normallyAcceptance criteria
frontend/src/lib/identus.tsexportscreateDIDForUser,issueVerifiableCredential,verifyUserCredentials,revokeCredentialIDENTUS_AGENT_URLis not setUser.didwhen a wallet is linked (link-walletroute)KYCCredentialissued andkycCredentialIdsaved after Didit webhook ApprovedEDDCredentialissued andeddCredentialIdsaved after bank statement acceptedInterviewCredentialissued andinterviewCredIdsaved after questionnaire submittedcase "lock"in orders PATCH)IDENTUS_AGENT_URL,IDENTUS_API_KEY,IDENTUS_ISSUER_DIDStack context
User.did,User.kycCredentialId,User.eddCredentialId,User.interviewCredIdalready exist — no migration neededfetch), not the npm SDK. This keeps the dependency surface small and avoids SDK version conflicts.did:prism(Cardano-anchored, managed by the Cloud Agent)frontend/src/lib/identus.tsalready hascreateDIDForUser()with stub fallback — extend this file, don't create a new one