Build a DID-Verified QR Payment System

Wire together decentralized identity, verifiable credentials, and QR payments.

Introduction

30 Second Summary

Every time you scan a QR code to pay for coffee, a chain of trust is at work behind the scenes. Someone verified your identity, encoded the merchant's details into that tiny square, and a system decided whether to let the money move.

In this project, you will build that entire chain yourself. You will create cryptographic identities, simulate bank-grade identity verification, generate real-world QR codes, and wire up a payment gate that only lets verified customers through.

What You'll Build

You will run a single demo command and watch your terminal print the full fintech pipeline: identities created, credentials issued, a QR code rendered, a payment rejected, and then the same payment authorized after presenting proof of identity.

By the end of this project, you'll have:

  • A running REST API where you hit one endpoint to create Decentralized Identifiers for a bank and a customer, then see both cryptographic identities printed in the response.
  • A credential issuance flow where you simulate an eKYC check and watch the bank sign a W3C Verifiable Credential proving the customer is verified.
  • A merchant QR code generator that produces the same EMVCo TLV format used by UPI, PromptPay, and PayNow globally, rendered right in your terminal.
  • A credential-gated payment endpoint that rejects a payment attempt without proof of identity, then authorizes the same payment once a valid credential is presented.
  • Secret Mission: Add credential expiry and a revocation mechanism so the bank can invalidate a customer's KYC credential after it has been issued.

Are there any prerequisites?

This project assumes you are familiar with Node.js backend development and have a basic understanding of REST APIs and JSON. No prior knowledge of decentralized identity or QR payment standards is needed.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

This project wires together decentralized identities, verifiable credentials, eKYC, and QR-based payments into a single working system. That pipeline is the backbone of modern fintech platforms, and building it yourself proves you understand how each piece connects.

Set Up the Project

To build the DID-verified payment system, you need a project that can compile TypeScript, talk to a DID/VC SDK, encode EMVCo QR data, and serve REST endpoints.

In this step, you will scaffold a Node.js TypeScript project, install every dependency the system needs, and confirm the server starts.

In this step, get ready to:
  • Initialize a TypeScript project with npm and create the compiler configuration.
  • Install the DID/VC, QR encoding, and Express dependencies.
  • Create the Express server skeleton and confirm it starts on port 3000.
Check your Node.js version

This project requires Node.js version 18 or higher because the SDK uses modern JavaScript features only available from that version onward.

  • Open a terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check your Node.js version by running:
node --version

✔️ I see version 18 or higher

You are good to go. Any version from v18 upward works for this project.

ⓧ I see a version lower than 18

You have Node.js installed, but it is too old for the dependencies this project uses.

The recommended way to upgrade is with nvm (Node Version Manager). On macOS or Linux, install nvm and then install a newer version by running:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.6/install.sh | bash
source ~/.bashrc
nvm install 22
nvm use 22

On Windows, download the latest LTS installer from https://nodejs.org/en/download and run it. The installer will replace your older version.

After upgrading, close and reopen your terminal, then run node --version again to confirm you see v18 or higher.

ⓧ Command not found

Node.js is not installed on your machine yet.

On macOS or Linux, install nvm and then install Node.js by running:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.6/install.sh | bash
source ~/.bashrc
nvm install 22

On Windows, download the LTS installer from https://nodejs.org/en/download and run it. The installer adds node and npm to your PATH automatically.

After installing, close and reopen your terminal, then run node --version to confirm the install succeeded.

Initialize the TypeScript project
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and move into it by running these commands:
mkdir did-qr-payment
cd did-qr-payment
  • Initialize a new Node.js project by running:
npm init -y

You should see npm print a JSON summary of the new package.json file it just created.

  • Install TypeScript, ts-node, and Node type definitions as dev dependencies by running:
npm install --save-dev typescript ts-node @types/node

What do these packages do?

The typescript package is the compiler that turns .ts files into JavaScript. The ts-node package lets you run TypeScript files directly without a separate compile step.

The @types/node package provides type definitions for Node.js built-in modules like Buffer and crypto.

  • Create the TypeScript compiler configuration. In VS Code, create a new file called tsconfig.json in the root of your did-qr-payment folder, then paste the following:
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true
  },
  "include": ["src/**/*"]
}

What does this configuration do?

The target and lib options tell TypeScript to compile for ES2020, which supports features like BigInt that the DID generation code needs.

The esModuleInterop flag lets you import CommonJS packages (like Express) using the familiar import x from 'y' syntax. The skipLibCheck flag speeds up compilation by skipping type checks on third-party declaration files.

  • Save tsconfig.json.
  • Confirm the file exists by running:
ls tsconfig.json

You should see tsconfig.json printed in the output.

Don't see tsconfig.json?

Make sure you saved the file in the did-qr-payment folder, not inside a subfolder. Run pwd to confirm your terminal is in the right directory.

Help me figure out where my file was saved.

Install project dependencies

The payment system uses four runtime libraries and two additional type packages.

  • Install the runtime dependencies by running:
npm install @attestto/vc-sdk express promptparse qrcode

What are these packages?

  • @attestto/vc-sdk handles DID key generation, Verifiable Credential issuance, and cryptographic verification.
  • promptparse encodes and parses EMVCo QR payloads using the TLV format that real payment networks like UPI and PromptPay use.
  • qrcode renders the encoded payload as a scannable QR image right in your terminal.
  • express is the web framework that serves the REST API endpoints.
  • Install the type definition packages by running:
npm install --save-dev @types/express @types/qrcode
  • Confirm the dependencies installed correctly by running:
ls node_modules/@attestto/vc-sdk node_modules/promptparse

You should see contents listed for both directories, confirming both packages are installed.

Seeing install errors?

If npm install fails, confirm you are inside the did-qr-payment folder and that package.json exists. Run ls package.json to check.

Also confirm your Node.js version is 18 or higher with node --version. Some packages fail to install on older versions.

Help me troubleshoot my npm install errors.

  • Update package.json to add the start script. Open package.json in VS Code and replace the "scripts" section with:
"scripts": {
  "start": "npx ts-node src/index.ts",
  "demo": "npx ts-node src/demo.ts"
}

What do these scripts do?

The start script runs the main server. The demo script runs an end-to-end demonstration of the full payment flow that you will build in Step 5.

  • While you have package.json open, also update the "description" field to "DID-Verified QR Payment System" and the "main" field to "dist/index.js".
  • Save package.json.
Create the Express server skeleton
  • Create the source directory by running:
mkdir src
  • In VS Code, create a new file at src/index.ts inside the did-qr-payment folder. Paste the following server skeleton:
import express from 'express';
import { createDID, DIDIdentity } from './did';
import { simulateEKYC, issueKYCCredential, KYCRequest } from './kyc';
import { generateMerchantQR, parseMerchantQR, renderQRToTerminal, MerchantQRData } from './qr';
import { verifyPaymentAuthorization, PaymentRequest } from './payment';

const app = express();
app.use(express.json());

// In-memory store for identities and credentials
const store: {
  bank?: DIDIdentity;
  customer?: DIDIdentity;
  credentials: Record<string, unknown>[];
} = { credentials: [] };

// Health check endpoint
app.get('/health', (_req, res) => {
  res.json({ status: 'ok', service: 'did-qr-payment' });
});

// Start the server
const PORT = 3000;
app.listen(PORT, () => {
  console.log(`DID Payment Server running on port ${PORT}`);
});

What does this code do?

  • The imports at the top reference modules you will create in the upcoming steps. TypeScript will show import errors for now, but that is expected.
  • The store object holds identities and credentials in memory so the API endpoints can share state.
  • The /health endpoint returns a simple JSON response so you can quickly confirm the server is alive.
  • app.listen starts the HTTP server on port 3000 and logs a confirmation message.
  • Save src/index.ts.

Before you run the server, what do you expect to see printed in the terminal?

  • Start the server by running:
npx ts-node src/index.ts

Why use npx here?

The ts-node binary is installed locally in node_modules. Using npx lets you run locally-installed binaries without referencing the full path.

You will see TypeScript warnings about the missing module files, but the server still starts. You should see this output:

DID Payment Server running on port 3000
  • Stop the server by pressing Ctrl+C in the terminal.

Server not starting?

If you see a module resolution error mentioning express, confirm that node_modules exists in your project folder. Run ls node_modules/express to check.

If you see a port-in-use error, another process is already using port 3000. Close it or wait a moment and try again.

Help me debug my Express server startup error.

✔️ Awesome, I've got everything!

Great. Double check you have saved both tsconfig.json and src/index.ts.

ⓧ I'd like to double check the full code

Here is the complete tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true
  },
  "include": ["src/**/*"]
}

Here is the complete src/index.ts for this step:

import express from 'express';
import { createDID, DIDIdentity } from './did';
import { simulateEKYC, issueKYCCredential, KYCRequest } from './kyc';
import { generateMerchantQR, parseMerchantQR, renderQRToTerminal, MerchantQRData } from './qr';
import { verifyPaymentAuthorization, PaymentRequest } from './payment';

const app = express();
app.use(express.json());

// In-memory store for identities and credentials
const store: {
  bank?: DIDIdentity;
  customer?: DIDIdentity;
  credentials: Record<string, unknown>[];
} = { credentials: [] };

// Health check endpoint
app.get('/health', (_req, res) => {
  res.json({ status: 'ok', service: 'did-qr-payment' });
});

// Start the server
const PORT = 3000;
app.listen(PORT, () => {
  console.log(`DID Payment Server running on port ${PORT}`);
});

Here is the complete package.json:

{
  "name": "did-qr-payment",
  "version": "1.0.0",
  "description": "DID-Verified QR Payment System",
  "main": "dist/index.js",
  "scripts": {
    "start": "npx ts-node src/index.ts",
    "demo": "npx ts-node src/demo.ts"
  },
  "dependencies": {
    "@attestto/vc-sdk": "^1.0.0",
    "express": "^4.21.0",
    "promptparse": "^1.6.0",
    "qrcode": "^1.5.4"
  },
  "devDependencies": {
    "@types/express": "^5.0.0",
    "@types/node": "^22.0.0",
    "@types/qrcode": "^1.5.0",
    "ts-node": "^10.9.0",
    "typescript": "^5.7.0"
  }
}

Version numbers may differ slightly

Your package.json version numbers depend on what npm resolved when you ran the install commands. The exact patch versions may differ from the reference above, and that is fine.

Your server is alive. Next up, you will give it the ability to create cryptographic identities using Decentralized Identifiers.

Create Decentralized Identities

Your server is running and healthy. Now it needs to know who is making requests.

In real fintech systems, every participant (banks, customers, merchants) needs a cryptographic identity before anything else can happen. Without one, there is no way to prove who issued a credential or who is requesting a payment. In this step, you will build the identity layer using Decentralized Identifiers (DIDs).

In this step, get ready to:
  • Build a DID creation utility that generates Ed25519 key pairs and constructs did:key identifiers.
  • Create REST endpoints that generate and retrieve identities for a bank and customer.
  • Verify that hitting the API returns two cryptographic identities.
Build the DID interface and key generation

A DID (Decentralized Identifier) is a globally unique identifier that is cryptographically verifiable. The did:key method derives the identifier entirely from a public key, so it works locally with no external registry.

  • Create a new file src/did.ts in your did-qr-payment project folder.
  • Add the import and the DIDIdentity interface by pasting this code:
import { generateKeyPair } from '@attestto/vc-sdk';

// Defines the shape of a DID identity with its key material
export interface DIDIdentity {
  did: string;
  publicKey: Uint8Array;
  privateKey: Uint8Array;
  algorithm: string;
}

What does this code do?

generateKeyPair from @attestto/vc-sdk generates cryptographic key pairs. The DIDIdentity interface describes everything an identity needs: its DID string, its public and private keys as raw bytes, and the algorithm used.

  • Below the interface in the same file, add the createDID() function that generates an Ed25519 key pair and constructs the did:key identifier:
export function createDID(): DIDIdentity {
  // Generate a new Ed25519 key pair
  const keys = generateKeyPair('Ed25519');

  // Prefix the public key with the multicodec Ed25519 identifier
  const MULTICODEC_ED25519_PREFIX = new Uint8Array([0xed, 0x01]);
  const prefixedKey = new Uint8Array(MULTICODEC_ED25519_PREFIX.length + keys.publicKey.length);
  prefixedKey.set(MULTICODEC_ED25519_PREFIX);
  prefixedKey.set(keys.publicKey, MULTICODEC_ED25519_PREFIX.length);

  // Base58-encode the prefixed key to form the DID
  const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
  let encoded = '';
  let num = BigInt('0x' + Buffer.from(prefixedKey).toString('hex'));
  while (num > 0n) {
    const remainder = num % 58n;
    encoded = ALPHABET[Number(remainder)] + encoded;
    num = num / 58n;
  }
  for (const byte of prefixedKey) {
    if (byte === 0) encoded = '1' + encoded;
    else break;
  }

  const did = `did:key:z${encoded}`;

  return {
    did,
    publicKey: keys.publicKey,
    privateKey: keys.privateKey,
    algorithm: keys.algorithm,
  };
}

What is happening in createDID()?

The function follows the W3C did:key specification. It generates a fresh Ed25519 key pair, then encodes the public key into a DID string using three steps.

  • Prepend the multicodec prefix [0xed, 0x01] to the raw public key bytes. This two-byte prefix tells any decoder that the key is Ed25519.
  • Base58-encode the prefixed bytes using the Bitcoin alphabet (no ambiguous characters like 0, O, I, l).
  • Prefix the result with z (indicating base58btc encoding) and prepend did:key: to form the final DID.

The resulting identifier looks like did:key:z6Mk... and is globally unique. Anyone with the DID can extract the public key to verify signatures.

  • Save src/did.ts.
  • Stop your running server with Ctrl+C in the terminal, then restart it by running:
npx ts-node src/index.ts

You should see DID Payment Server running on port 3000 again with no errors. This confirms TypeScript can find and compile your new src/did.ts module.

Seeing a compilation error?

  • Check that src/did.ts exports both DIDIdentity and createDID. The src/index.ts file imports both of these.
  • Make sure your file is saved in the src/ directory, not in the project root.

Still stuck? Help me debug a TypeScript compilation error in my DID module.

Create the /api/identities endpoint

With the DID utility ready, you can wire it into an API endpoint that creates identities for both participants in the payment flow: a bank (credential issuer) and a customer (credential holder).

  • Stop the server with Ctrl+C.
  • In src/index.ts, find the closing of the health check endpoint:
app.get('/health', (_req, res) => {
  res.json({ status: 'ok', service: 'did-qr-payment' });
});
  • Directly below it, add the /api/identities endpoint:
app.get('/api/identities', (_req, res) => {
  // Generate fresh DIDs for both participants
  store.bank = createDID();
  store.customer = createDID();

  res.json({
    bank: { did: store.bank.did, role: 'issuer' },
    customer: { did: store.customer.did, role: 'holder' },
  });
});

What does this endpoint do?

Each call to createDID() generates a unique cryptographic identity. The endpoint creates two identities and stores them in memory. The bank acts as the issuer (it will sign credentials later). The customer acts as the holder (it will present credentials for payment).

  • Save src/index.ts and restart the server:
npx ts-node src/index.ts
  • Open a second terminal and test the endpoint by running:
curl http://localhost:3000/api/identities

You should see a JSON response with two DID documents. Each contains a did:key:z6Mk... identifier and a role.

Getting an empty response or connection error?

  • Make sure the server is running in your first terminal and shows DID Payment Server running on port 3000.
  • Confirm you added the new endpoint after the health check's closing }); and before const PORT = 3000;.

Still stuck? Help me debug why my /api/identities endpoint isn't returning DID documents.

Add the /api/identities/:role endpoint

Other parts of the system will need to look up a specific identity by role. This endpoint lets you retrieve just the bank or just the customer identity.

  • Stop the server with Ctrl+C.
  • In src/index.ts, directly below the /api/identities endpoint you just added, paste this:
app.get('/api/identities/:role', (req, res) => {
  // Look up the identity by role (bank or customer)
  const identity = store[req.params.role as 'bank' | 'customer'];
  if (!identity) {
    res.status(404).json({ error: `Identity for role "${req.params.role}" not found` });
    return;
  }
  res.json({ did: identity.did, role: req.params.role });
});

What does this endpoint do?

The :role parameter in the URL is a dynamic segment. Express extracts it from the path, and the handler uses it to look up the matching identity in the store. If the identity has not been created yet (the learner has not called /api/identities first), it returns a 404.

  • Save src/index.ts and restart the server:
npx ts-node src/index.ts
  • In your second terminal, first create the identities:
curl http://localhost:3000/api/identities
  • Then retrieve just the bank identity by running:
curl http://localhost:3000/api/identities/bank

You should see just the bank's DID and its role as issuer. Before you run this, what do you think will happen if you request a role that does not exist?

  • Test the error case by running:
curl http://localhost:3000/api/identities/merchant

You see a 404 with the message Identity for role "merchant" not found. The system only knows about bank and customer roles at this point.

Getting 404 for bank or customer too?

  • You must call GET /api/identities first to generate the identities. The /api/identities/:role endpoint only retrieves existing identities from the in-memory store.
  • If you restarted the server between calls, the in-memory store resets. Call /api/identities again to recreate them.

Need help? Why is my /api/identities/:role endpoint returning 404 even for bank or customer?

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/did.ts and src/index.ts.

ⓧ I'd like to double check the full code

Confirm your src/did.ts contains, in order:

  • The import { generateKeyPair } line at the top.
  • The exported DIDIdentity interface with four fields: did, publicKey, privateKey, algorithm.
  • The exported createDID() function that generates keys, applies the multicodec prefix, base58-encodes, and returns a DIDIdentity.

Confirm your src/index.ts has both new endpoints between the /health endpoint and const PORT = 3000;:

  • app.get('/api/identities', ...) that calls createDID() twice and stores results in store.bank and store.customer.
  • app.get('/api/identities/:role', ...) that looks up store[req.params.role] and returns a 404 if not found.

You now have a working identity layer. Every call to /api/identities mints fresh cryptographic identities that will anchor all the credentials and payments you build next.

Simulate eKYC and Issue a Verifiable Credential

You now have cryptographic identities for a bank and a customer. But an identity alone proves nothing about the person behind it.

In the real world, a bank runs an eKYC (electronic Know Your Customer) check before trusting someone. This step builds that verification pipeline. Once the customer passes eKYC, the bank issues a signed Verifiable Credential that proves verification happened.

In this step, get ready to:
  • Build a mock eKYC verification function that simulates document and identity checks.
  • Issue a signed W3C Verifiable Credential using the bank's DID.
  • Wire up a REST endpoint that runs eKYC and returns the credential.
Build the eKYC simulation function

A real eKYC check involves document scanning, face matching, and OTP verification. Your mock simulates that delay and validation logic so the rest of the pipeline has something to gate on.

  • Create a new file src/kyc.ts in your did-qr-payment project by running:
touch src/kyc.ts
  • Confirm the file exists by running:
ls src/

You should see kyc.ts alongside did.ts and index.ts.

  • Open src/kyc.ts in VS Code and add the imports, interfaces, and eKYC simulation function:
import { VCIssuer } from '@attestto/vc-sdk';
import { DIDIdentity } from './did';

export interface KYCRequest {
  customerDid: string;
  fullName: string;
  documentNumber: string;
  documentType: string;
}

export interface KYCResult {
  verified: boolean;
  credential?: Record<string, unknown>;
  error?: string;
}

export async function simulateEKYC(request: KYCRequest): Promise<boolean> {
  // Simulate real-world processing delay (document scan + OTP)
  await new Promise((resolve) => setTimeout(resolve, 500));

  // Reject if any required field is missing
  if (!request.fullName || !request.documentNumber || !request.documentType) {
    return false;
  }

  return true;
}

What does this code do?

  • KYCRequest defines the data a customer submits for identity verification: their DID, name, and government document details.
  • KYCResult is the shape of what the endpoint will return: a pass/fail flag plus the issued credential on success.
  • simulateEKYC() adds a 500ms delay to mimic real processing, then rejects requests with missing fields. A real implementation would call a third-party identity verification API here.
  • Save src/kyc.ts.
Issue a signed KYC credential with VCIssuer

Once eKYC passes, the bank needs to issue cryptographic proof. The VCIssuer class from @attestto/vc-sdk signs a credential with the bank's private key so anyone can later verify it came from a trusted issuer.

  • In src/kyc.ts, add the issueKYCCredential() function below simulateEKYC():
export async function issueKYCCredential(
  bankIdentity: DIDIdentity,
  customerDid: string,
  kycData: KYCRequest
): Promise<Record<string, unknown>> {
  // Create an issuer using the bank's DID and private key
  const issuer = new VCIssuer({
    did: bankIdentity.did,
    privateKey: bankIdentity.privateKey,
  });

  // Issue a signed Verifiable Credential with the customer's claims
  const credential = await issuer.issue({
    type: 'KYCCredential',
    context: 'https://schemas.example.org/kyc/v1',
    subjectDid: customerDid,
    claims: {
      fullName: kycData.fullName,
      documentNumber: kycData.documentNumber,
      documentType: kycData.documentType,
      verificationLevel: 'standard',
      verifiedAt: new Date().toISOString(),
    },
  });

  return credential as Record<string, unknown>;
}

What does this code do?

  • VCIssuer takes the bank's DID and private key. It uses these to cryptographically sign every credential it produces.
  • issuer.issue() creates a W3C-compliant Verifiable Credential with type KYCCredential. The claims object holds the verified identity data that any verifier can later read.
  • The verificationLevel and verifiedAt fields record what level of check was performed and when it happened.
  • Save src/kyc.ts.

✔️ Awesome, I've got everything!

Great. Make sure you saved the file before moving on.

ⓧ I'd like to double check the full code

Your src/kyc.ts should contain these sections in order:

  • Imports from @attestto/vc-sdk and ./did.
  • The KYCRequest interface with four string fields.
  • The KYCResult interface with verified, credential, and error fields.
  • The simulateEKYC() function with a 500ms delay and field validation.
  • The issueKYCCredential() function that creates a VCIssuer and issues a signed credential.

Scroll up and confirm each chunk is present in the file.

Wire up the /api/kyc/verify endpoint

The eKYC logic is ready but nothing calls it yet. You need a REST endpoint that accepts a customer's details, runs the verification, and returns a signed credential on success.

  • In src/index.ts, find the /api/identities/:role endpoint. Add the following code below its closing }); and above const PORT = 3000;:
app.post('/api/kyc/verify', async (req, res) => {
  // Require identities to exist before running eKYC
  if (!store.bank || !store.customer) {
    res.status(400).json({ error: 'Identities not created. Call GET /api/identities first.' });
    return;
  }

  // Build the KYC request with defaults for easy testing
  const kycRequest: KYCRequest = {
    customerDid: store.customer.did,
    fullName: req.body.fullName || 'Alice Johnson',
    documentNumber: req.body.documentNumber || 'PASS-12345678',
    documentType: req.body.documentType || 'passport',
  };

  // Run eKYC simulation
  const verified = await simulateEKYC(kycRequest);
  if (!verified) {
    res.status(400).json({ verified: false, error: 'eKYC verification failed' });
    return;
  }

  // Issue credential and store it (simulating a wallet)
  const credential = await issueKYCCredential(store.bank, store.customer.did, kycRequest);
  store.credentials.push(credential);

  res.json({ verified: true, credential });
});

What does this endpoint do?

  • First it checks that identities exist. Without them, there is no bank to sign the credential and no customer DID to attach it to.
  • It builds a KYCRequest with sensible defaults so you can test quickly without passing a body.
  • On success, it stores the credential in store.credentials. This simulates a digital wallet holding your issued credential for later use.
  • Save src/index.ts.

Seeing red underlines on the import?

If VS Code shows an error on the import { simulateEKYC, issueKYCCredential, KYCRequest } from './kyc' line, make sure you saved src/kyc.ts first. The import resolves to that file.

Also check that the function names in your kyc.ts use the export keyword. Without it, other files cannot see them.

Help me debug my kyc.ts import errors.

Now test the full eKYC and credential issuance flow.

  • Stop your server if it is running (press Ctrl+C in the terminal), then restart it by running:
npx ts-node src/index.ts

You should see DID Payment Server running on port 3000 in your terminal.

  • First, create the identities that the eKYC endpoint needs. Open a second terminal and run:
curl http://localhost:3000/api/identities

You should see the bank and customer DIDs returned.

Before you hit the eKYC endpoint, what do you think will come back? A simple pass/fail, or something more?

  • Run the eKYC verification by hitting the new endpoint:
curl -X POST http://localhost:3000/api/kyc/verify -H "Content-Type: application/json" -d '{"fullName": "Alice Johnson", "documentNumber": "PASS-12345678", "documentType": "passport"}'

You should see a JSON response with "verified": true and a credential object. Inside that credential, look for type KYCCredential, the bank's did:key:z6Mk... as the issuer, and the customer's identity claims in credentialSubject.

What is a Verifiable Credential?

A Verifiable Credential is a digitally signed document that proves a claim about someone. It works like a tamper-proof digital certificate.

The bank's private key signs it, so anyone with the bank's public key can verify it was genuinely issued. No phone call to the bank needed.

Getting a 400 error about identities?

If you see Identities not created. Call GET /api/identities first. in the response, you skipped the first curl command. The server stores identities in memory, so you need to call GET /api/identities before running eKYC.

If you restarted the server between commands, the in-memory store was wiped. Run the identities call again.

Help me debug my /api/kyc/verify endpoint.

Your system can now verify a customer's identity and issue cryptographic proof. Next up, you will generate the merchant QR code that kicks off the actual payment.

Generate Merchant QR Codes

You now have a verified customer with a signed KYC credential. But credentials alone don't move money.

Merchant-presented QR codes are how billions of real payments start across systems like UPI, PromptPay, and PayNow. They all use the same underlying format: the EMVCo Merchant-Presented Mode specification. In this step, you will build the payment initiation point using that exact TLV (Tag-Length-Value) encoding.

In this step, get ready to:
  • Build a QR generation module that encodes merchant data into the EMVCo TLV format.
  • Create an API endpoint that returns the encoded payload and renders a QR code in your terminal.
  • Add a parse endpoint that decodes the TLV string back into structured data.
Build the QR generation module

The EMVCo standard uses Tag-Length-Value encoding where each piece of payment data gets a 2-digit tag ID, a 2-digit length, and a variable-length value. You will build a module that assembles these tags into a compliant payload.

  • Create a new file src/qr.ts in VS Code.
  • Add the imports and the MerchantQRData interface by pasting this code:
import { encode, tag, withCrcTag, parse } from 'promptparse';
import * as QRCode from 'qrcode';

// Define the shape of merchant payment data
export interface MerchantQRData {
  merchantName: string;
  merchantCity: string;
  amount?: number;
  accountId: string;
  countryCode: string;
  currencyCode: string;
  merchantCategoryCode: string;
}

What does this code do?

The promptparse library provides four functions for working with EMVCo TLV data: encode builds the TLV string, tag creates individual tag entries, withCrcTag appends the CRC-16 checksum, and parse decodes a TLV string back into structured data.

The MerchantQRData interface defines the fields a merchant needs to generate a payment QR code: name, city, account ID, currency, and an optional amount.

  • Add the generateMerchantQR() function below the interface in src/qr.ts:
export function generateMerchantQR(data: MerchantQRData): string {
  // Build the array of EMVCo TLV tags
  const tags = [
    tag('00', '01'),                    // Format indicator (always 01)
    tag('01', data.amount ? '12' : '11'), // Point of initiation: 12=dynamic, 11=static
    tag('26', encode([                   // Merchant account info (nested TLV)
      tag('00', 'com.example.merchant'),
      tag('01', data.accountId),
    ])),
    tag('52', data.merchantCategoryCode), // Merchant Category Code
    tag('53', data.currencyCode),         // Transaction currency
  ];

  // Only include amount tag if specified
  if (data.amount) {
    tags.push(tag('54', data.amount.toFixed(2)));
  }

  tags.push(tag('58', data.countryCode));  // Country code
  tags.push(tag('59', data.merchantName)); // Merchant name
  tags.push(tag('60', data.merchantCity)); // Merchant city

  // Encode all tags and append CRC checksum as tag 63
  return withCrcTag(encode(tags), '63');
}

What do these EMVCo tags mean?

Each 2-digit number is a standard EMVCo tag. Tag 00 is always the format indicator. Tag 01 says whether the QR is static (reusable) or dynamic (one-time with a specific amount).

Tag 26 is the merchant account information, which itself contains nested TLV data. Tag 63 is the CRC checksum that validates the payload was not corrupted. These are the same tags used by real payment systems globally.

  • Save src/qr.ts.
  • Confirm the file exists by running:
ls src/

You should see qr.ts listed alongside did.ts, index.ts, and kyc.ts.

Add parsing and rendering helpers

The generation function builds a TLV string, but you also need to decode one back into structured data and render it as a scannable QR image in the terminal.

  • Add the parseMerchantQR() and renderQRToTerminal() functions at the bottom of src/qr.ts:
export function parseMerchantQR(payload: string) {
  // Decode TLV string back into individual tag values
  const parsed = parse(payload);
  return {
    formatIndicator: parsed.getTagValue('00'),
    pointOfInitiation: parsed.getTagValue('01'),
    merchantCategoryCode: parsed.getTagValue('52'),
    currencyCode: parsed.getTagValue('53'),
    amount: parsed.getTagValue('54'),
    countryCode: parsed.getTagValue('58'),
    merchantName: parsed.getTagValue('59'),
    merchantCity: parsed.getTagValue('60'),
    crc: parsed.getTagValue('63'),
  };
}

export async function renderQRToTerminal(payload: string): Promise<string> {
  // Render the payload as a scannable QR code using terminal characters
  return QRCode.toString(payload, { type: 'terminal', small: true });
}

What do these functions do?

The parseMerchantQR() function takes a raw TLV string and extracts each tag value by ID. This is the reverse of generateMerchantQR() and proves the round-trip works.

The renderQRToTerminal() function uses the qrcode package to convert any string into a scannable QR image drawn with terminal block characters.

  • Save src/qr.ts.
  • Restart your server to confirm the new module resolves by running:
npx ts-node src/index.ts

You should see DID Payment Server running on port 3000 with no import errors.

Seeing a module not found error?

If you see an error about ./qr not being found, make sure src/qr.ts is saved in the src folder (not the project root). The filename must match exactly: qr.ts.

help me debug a module not found error for src/qr.ts

✔️ Awesome, I've got everything!

Great. Double check you have saved src/qr.ts and your server starts cleanly.

ⓧ I'd like to double check the full code

Here is the complete src/qr.ts file. Compare yours against it:

import { encode, tag, withCrcTag, parse } from 'promptparse';
import * as QRCode from 'qrcode';

export interface MerchantQRData {
  merchantName: string;
  merchantCity: string;
  amount?: number;
  accountId: string;
  countryCode: string;
  currencyCode: string;
  merchantCategoryCode: string;
}

export function generateMerchantQR(data: MerchantQRData): string {
  const tags = [
    tag('00', '01'),
    tag('01', data.amount ? '12' : '11'),
    tag('26', encode([
      tag('00', 'com.example.merchant'),
      tag('01', data.accountId),
    ])),
    tag('52', data.merchantCategoryCode),
    tag('53', data.currencyCode),
  ];

  if (data.amount) {
    tags.push(tag('54', data.amount.toFixed(2)));
  }

  tags.push(tag('58', data.countryCode));
  tags.push(tag('59', data.merchantName));
  tags.push(tag('60', data.merchantCity));

  return withCrcTag(encode(tags), '63');
}

And the parsing and rendering section:

export function parseMerchantQR(payload: string) {
  const parsed = parse(payload);
  return {
    formatIndicator: parsed.getTagValue('00'),
    pointOfInitiation: parsed.getTagValue('01'),
    merchantCategoryCode: parsed.getTagValue('52'),
    currencyCode: parsed.getTagValue('53'),
    amount: parsed.getTagValue('54'),
    countryCode: parsed.getTagValue('58'),
    merchantName: parsed.getTagValue('59'),
    merchantCity: parsed.getTagValue('60'),
    crc: parsed.getTagValue('63'),
  };
}

export async function renderQRToTerminal(payload: string): Promise<string> {
  return QRCode.toString(payload, { type: 'terminal', small: true });
}
Create the merchant QR endpoint

The QR module is ready. Now you need an API endpoint that accepts merchant details and returns the encoded EMVCo payload.

  • Stop the running server with Ctrl+C.
  • In src/index.ts, find the const PORT = 3000; line near the bottom of the file.
  • Add the following endpoint above that line:
app.post('/api/merchant/qr', async (req, res) => {
  // Build merchant data from request body with sensible defaults
  const data: MerchantQRData = {
    merchantName: req.body.merchantName || 'Coffee Shop',
    merchantCity: req.body.merchantCity || 'Singapore',
    amount: req.body.amount,
    accountId: req.body.accountId || 'ACC-001-MERCHANT',
    countryCode: req.body.countryCode || 'SG',
    currencyCode: req.body.currencyCode || '702',
    merchantCategoryCode: req.body.merchantCategoryCode || '5812',
  };

  // Generate the EMVCo TLV payload
  const payload = generateMerchantQR(data);
  const terminalQR = await renderQRToTerminal(payload);

  // Print the QR code to the server terminal
  console.log('\n--- Merchant QR Code ---');
  console.log(terminalQR);

  // Return the raw payload and its parsed form
  res.json({ payload, parsed: parseMerchantQR(payload) });
});

What does this endpoint do?

The endpoint accepts merchant details (name, city, amount, account ID) and passes them to generateMerchantQR(). It returns the raw EMVCo TLV string so a client could encode it as a QR image, and also prints a QR code directly in your server terminal using block characters.

The defaults (Coffee Shop, Singapore, currency code 702 for SGD, MCC 5812 for restaurants) let you test without providing every field.

  • Save src/index.ts and restart the server:
npx ts-node src/index.ts

Before you test this endpoint, what do you expect the response to contain? Think about what generateMerchantQR() returns.

  • Open a second terminal and hit the endpoint by running:
curl -s -X POST http://localhost:3000/api/merchant/qr \
  -H "Content-Type: application/json" \
  -d '{"merchantName": "Coffee Shop", "merchantCity": "Singapore", "amount": 5.50}' | npx json

You should see a JSON response with a payload field containing a long TLV-encoded string, and a parsed object showing the decoded merchant name, city, and amount.

Switch to the terminal running your server. You should see a QR code rendered with block characters under the --- Merchant QR Code --- header.

Not seeing a QR code in the server terminal?

Make sure you are looking at the terminal where npx ts-node src/index.ts is running, not the terminal where you ran curl. The QR renders on the server side via console.log.

If you see an error about MerchantQRData not being found, confirm the import line at the top of src/index.ts includes MerchantQRData.

help me debug why the merchant QR endpoint is not returning data

Add the QR parse endpoint

A parse endpoint proves the round-trip: you can take a raw EMVCo string and decode it back into readable fields. This is how a payment app would interpret a scanned QR code.

  • Stop the server with Ctrl+C.
  • In src/index.ts, add this endpoint directly below the /api/merchant/qr endpoint you just added:
app.post('/api/merchant/qr/parse', (req, res) => {
  if (!req.body.payload) {
    res.status(400).json({ error: 'payload field required' });
    return;
  }
  // Decode the raw TLV string into structured merchant data
  res.json(parseMerchantQR(req.body.payload));
});

What does this endpoint do?

It takes a raw EMVCo TLV string (the payload field) and runs it through parseMerchantQR() to extract all the tag values. In a real payment app, this is what happens when a customer scans a merchant's QR code.

  • Save src/index.ts and restart the server:
npx ts-node src/index.ts
  • Test the parse endpoint by first generating a QR payload, then feeding it to the parse endpoint. Run this in your second terminal:
curl -s -X POST http://localhost:3000/api/merchant/qr \
  -H "Content-Type: application/json" \
  -d '{"merchantName": "Coffee Shop", "amount": 5.50}' > /tmp/qr.json

curl -s -X POST http://localhost:3000/api/merchant/qr/parse \
  -H "Content-Type: application/json" \
  -d "{\"payload\": $(cat /tmp/qr.json | npx json payload | xargs -I{} echo '"{}"')}" | npx json

You should see the decoded fields: merchantName showing Coffee Shop, amount showing 5.50, and countryCode showing SG. The round-trip proves your EMVCo encoding is correct.

Getting a 'payload field required' error?

The nested curl command above is complex. If it fails, run the simpler two-step version: first run the generate curl and copy the payload value from the response, then paste it directly into a second curl request to /api/merchant/qr/parse.

help me test the QR parse endpoint by extracting the payload from the generate response

Your payment system can now generate and parse QR codes following the same standard used by real payment networks worldwide. Next up, you will wire the credential verification to payment authorization so only verified customers can pay.

Authorize Payment with Verifiable Credential

You've built the three pillars of the system: cryptographic identities, signed credentials, and merchant QR codes. Each piece works in isolation, but right now there's nothing stopping an unverified customer from processing a payment.

This step connects the entire pipeline. You'll build the payment authorization gate that demands a valid Verifiable Credential before approving any transaction. Then you'll run the full flow end-to-end and watch an unverified request get rejected.

In this step, get ready to:
  • Build a payment verification function that cryptographically checks credentials before authorizing.
  • Test the credential gate by attempting payment without and with a valid credential.
  • Create an end-to-end demo script that runs the entire identity-to-payment pipeline.
Build the payment verification module

The payment authorization function is the checkpoint that ties identity to payment. It takes a payment request containing a QR payload and an optional credential, then uses VCVerifier to cryptographically prove the credential is genuine before approving.

  • Create a new file called src/payment.ts in VS Code.
  • Add the imports, interfaces, and the opening of the verification function by pasting this code:
import { VCVerifier } from '@attestto/vc-sdk';
import { DIDIdentity } from './did';
import { parseMerchantQR } from './qr';

// Shape of an incoming payment request
export interface PaymentRequest {
  qrPayload: string;
  credential?: Record<string, unknown>;
}

// Shape of the authorization result
export interface PaymentResult {
  authorized: boolean;
  transactionId?: string;
  merchantName?: string;
  amount?: string;
  error?: string;
}

export async function verifyPaymentAuthorization(
  request: PaymentRequest,
  trustedBankIdentity: DIDIdentity
): Promise<PaymentResult> {
  // Reject immediately if no credential is attached
  if (!request.credential) {
    return { authorized: false, error: 'KYC credential required' };
  }

What does this code do?

The PaymentRequest interface requires a QR payload (the scanned merchant data) and optionally accepts a credential. The first guard clause rejects any request missing a credential with a clear error message.

This is the gate. Without it, anyone could scan a QR and process a payment with no identity check.

  • Now add the rest of the function body directly below the credential check. This code performs the cryptographic verification and returns the transaction result:
  // Cryptographically verify the credential signature
  const verifier = new VCVerifier();
  const result = await verifier.verifyWithKey(
    request.credential,
    trustedBankIdentity.publicKey,
    'Ed25519',
    {
      expectedType: 'KYCCredential',
      expectedIssuer: trustedBankIdentity.did,
    }
  );

  if (!result.valid) {
    return {
      authorized: false,
      error: `Credential verification failed: ${result.errors?.join(', ') || 'unknown error'}`,
    };
  }

  // Parse the QR payment data and generate a transaction ID
  const qrData = parseMerchantQR(request.qrPayload);
  const transactionId = `TXN-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;

  return {
    authorized: true,
    transactionId,
    merchantName: qrData.merchantName,
    amount: qrData.amount,
  };
}

What does the verifier check?

  • verifyWithKey() checks that the credential's digital signature was produced by the bank's private key. If someone tampered with the credential data, the signature won't match.
  • expectedType: 'KYCCredential' ensures only KYC credentials are accepted. A credential of a different type would be rejected.
  • expectedIssuer confirms the credential came from the trusted bank's DID, not from some other issuer.
  • Save src/payment.ts.

What about the endpoint in index.ts?

Your src/index.ts already has the /api/payment/authorize endpoint and the import from ./payment wired up. Now that you've created src/payment.ts, the endpoint has a working module to call.

  • Restart your server by pressing Ctrl+C in the terminal, then running:
npx ts-node src/index.ts

You should see "DID Payment Server running on port 3000" confirming the server starts cleanly with the payment module loaded.

Seeing a module not found error?

  • Check that your file is saved as src/payment.ts (not src/payments.ts or payment.ts in the root).
  • Confirm the function is named verifyPaymentAuthorization and is exported with the export keyword.

Help me debug my payment module import error

✔️ Awesome, I've got everything!

Great. Make sure you've saved your file before moving on to testing.

ⓧ I'd like to double check the full code

Your src/payment.ts should contain these sections in order:

  • Three imports: VCVerifier from the VC SDK, DIDIdentity from ./did, and parseMerchantQR from ./qr.
  • PaymentRequest interface with qrPayload and optional credential.
  • PaymentResult interface with authorized, transactionId, merchantName, amount, and error.
  • The verifyPaymentAuthorization() function with the credential check, VCVerifier call, validity check, QR parsing, and return.
Test the credential gate

This is the moment everything connects. You'll attempt a payment without a credential and watch the system reject it. Then you'll supply a valid credential and watch it succeed.

  • Open a second terminal (keep the server running in the first one).
  • First, create identities and run eKYC so you have a credential to work with. Run this command:
curl http://localhost:3000/api/identities
  • Issue a KYC credential by running:
curl -X POST http://localhost:3000/api/kyc/verify -H "Content-Type: application/json" -d '{"fullName":"Alice Johnson","documentNumber":"PASS-12345678","documentType":"passport"}'

You should see a JSON response with "verified": true and a credential object. Keep this terminal output visible.

  • Generate a merchant QR payload by running:
curl -X POST http://localhost:3000/api/merchant/qr -H "Content-Type: application/json" -d '{"merchantName":"Coffee Shop","merchantCity":"Singapore","amount":5.50}'

You'll see a payload string in the response. This is the EMVCo TLV data that a payment request needs.

Now for the designed obstacle. Before you run the next command, predict: what do you think will happen if you send a payment request with only the QR payload and NO credential?

  • Attempt a payment WITHOUT a credential by running:
curl -X POST http://localhost:3000/api/payment/authorize -H "Content-Type: application/json" -d '{"qrPayload":"000201010212..."}'

You get a 401 response with "error": "KYC credential required". The gate works. Without a signed credential from a trusted bank, the payment system refuses to process anything.

Why does this rejection matter?

This is the difference between a decorative identity system and a functional one. The DID and VC infrastructure you built in earlier steps only proves its value here, at the moment of transaction. In production, this same pattern prevents money laundering, fraud, and regulatory violations.

Testing the full flow with a real credential requires passing the complete credential JSON from the eKYC response into the payment request. That's cumbersome to do by hand in curl. This is exactly why you'll build a demo script next.

Create the end-to-end demo script

The demo script automates the entire pipeline in sequence: create identities, verify KYC, generate a QR, attempt payment without a credential (watch it fail), then attempt with a credential (watch it succeed).

  • Create a new file called src/demo.ts in VS Code.
  • Add the opening section that creates identities and runs eKYC:
const BASE_URL = 'http://localhost:3000';

async function demo() {
  console.log('=== DID-Verified QR Payment System Demo ===\n');

  // Step 1: Create cryptographic identities for both parties
  console.log('1. Creating identities...');
  const identitiesRes = await fetch(`${BASE_URL}/api/identities`);
  const identities = await identitiesRes.json();
  console.log(`   Bank DID: ${identities.bank.did}`);
  console.log(`   Customer DID: ${identities.customer.did}\n`);

  // Step 2: Run eKYC and receive a signed credential
  console.log('2. Running eKYC verification...');
  const kycRes = await fetch(`${BASE_URL}/api/kyc/verify`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      fullName: 'Alice Johnson',
      documentNumber: 'PASS-12345678',
      documentType: 'passport',
    }),
  });
  const kycResult = await kycRes.json();
  console.log(`   eKYC Status: ${kycResult.verified ? 'VERIFIED' : 'FAILED'}`);
  console.log(`   Credential Issued: Yes\n`);

What does this code do?

  • The script uses Node.js's built-in fetch (available in Node 18+) to call your local API endpoints in sequence.
  • Each step logs its result to the terminal so you can watch the pipeline unfold.
  • Add the QR generation and the first payment attempt (without a credential) directly below:
  // Step 3: Generate a merchant QR code
  console.log('3. Generating merchant QR code...');
  const qrRes = await fetch(`${BASE_URL}/api/merchant/qr`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      merchantName: 'Coffee Shop',
      merchantCity: 'Singapore',
      amount: 5.50,
      accountId: 'ACC-001-MERCHANT',
    }),
  });
  const qrResult = await qrRes.json();
  console.log(`   EMVCo Payload: ${qrResult.payload}`);
  console.log(`   Merchant: ${qrResult.parsed.merchantName}`);
  console.log(`   Amount: ${qrResult.parsed.amount}\n`);

  // Step 4: Attempt payment WITHOUT credential (should get 401)
  console.log('4. Attempting payment WITHOUT credential...');
  const failRes = await fetch(`${BASE_URL}/api/payment/authorize`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ qrPayload: qrResult.payload }),
  });
  const failResult = await failRes.json();
  console.log(`   Status: ${failRes.status} - ${failResult.error}\n`);

What does this code do?

Step 4 deliberately omits the credential from the payment request. This triggers the 401 rejection you tested manually. The demo script makes it obvious that the gate catches unverified requests.

  • Add the final section that attempts payment WITH the credential, closes the function, and calls it:
  // Step 5: Attempt payment WITH credential (should succeed)
  console.log('5. Attempting payment WITH credential...');
  const successRes = await fetch(`${BASE_URL}/api/payment/authorize`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      qrPayload: qrResult.payload,
      credential: kycResult.credential,
    }),
  });
  const successResult = await successRes.json();
  console.log(`   Status: ${successRes.status} - Payment ${successResult.authorized ? 'AUTHORIZED' : 'REJECTED'}`);
  console.log(`   Transaction ID: ${successResult.transactionId}`);
  console.log(`   Merchant: ${successResult.merchantName}`);
  console.log(`   Amount: ${successResult.amount}\n`);

  console.log('=== Demo Complete ===');
}

demo().catch(console.error);

What does this code do?

This time the request includes kycResult.credential from the eKYC step. The verifier checks the signature, confirms the issuer matches the trusted bank, and approves the payment with a generated transaction ID.

  • Save src/demo.ts.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file before running the demo.

ⓧ I'd like to double check the full code

Your src/demo.ts should contain these sections in order:

  • BASE_URL constant set to http://localhost:3000.
  • The demo() async function containing steps 1 through 5.
  • Step 1: fetch /api/identities and log both DIDs.
  • Step 2: POST to /api/kyc/verify with Alice's details.
  • Step 3: POST to /api/merchant/qr with merchant details.
  • Step 4: POST to /api/payment/authorize WITHOUT credential.
  • Step 5: POST to /api/payment/authorize WITH credential.
  • demo().catch(console.error) at the bottom to run it.

Before you run the demo, predict: will the first payment attempt (step 4 in the script) return a 200 or a 401? What about the second attempt with the credential?

  • Make sure your server is still running in the first terminal. In your second terminal, run the demo by executing:
npx ts-node src/demo.ts

You'll see the entire pipeline unfold in your terminal. Step 4 prints Status: 401 - KYC credential required. Step 5 prints Status: 200 - Payment AUTHORIZED with a transaction ID, merchant name, and amount.

Demo failing with a connection error?

  • Make sure your server is running in a separate terminal window. The demo script connects to localhost:3000 which needs the server process alive.
  • If you see a TypeScript error, check that src/demo.ts doesn't have any unclosed braces or missing parentheses.
  • If step 5 returns a verification failure instead of AUTHORIZED, confirm your eKYC step ran successfully (the credential must be issued in the same server session).

Help me debug my demo script

Your full fintech pipeline is working end-to-end. Identity creation, KYC verification, credential issuance, QR generation, and credential-gated payment authorization all connect into a single flow. Next up, take on the Secret Mission to add credential expiry and revocation.

Secret mission

Add Credential Expiry and Revocation

Right now, a KYC credential issued once is valid forever. No regulator would accept that. In this mission, you'll add time-based expiry and a revocation mechanism so the bank can invalidate credentials after issuance.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with in-memory data only, so there are no ongoing costs.

Resources you used:

  • Local did-qr-payment project folder on your Desktop (TypeScript source files, node_modules, config files).
  • Express server running on port 3000 (in-memory data only, no persistent storage).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep the project as a portfolio piece.

  • The project runs locally with no ongoing cost. No cloud resources exist to bill you.
  • Your Express server stays running until you close the terminal or press Ctrl+C.
  • All data is in-memory and resets each time you restart the server.

Pause - I'll come back to this later

Shut down the running server to free up port 3000 and terminal resources. Your project files stay on disk.

  • Switch to the terminal where the server is running and press Ctrl+C to stop it.
  • Your source files and node_modules remain in the did-qr-payment folder on your Desktop.
  • When you return, open the project folder in VS Code and run npx ts-node src/index.ts to start again.

Delete - I don't want to use this again

Remove the entire project folder. No external resources exist to clean up.

  • If the server is still running, press Ctrl+C in its terminal to stop it.
  • Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/did-qr-payment
  • On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\did-qr-payment
  • Confirm the folder is gone by running ls ~/Desktop (macOS/Linux) or dir %USERPROFILE%\Desktop (Windows). You should no longer see did-qr-payment listed.

Nice Work!

Nice Work!

You just built a full fintech identity-to-payment pipeline from scratch. Your system creates cryptographic identities, issues verifiable credentials after eKYC, generates real-world QR codes, and gates payments behind credential verification.

You've learned how to:

  • Build decentralized identities using the did:key method with Ed25519 cryptography, and issue signed W3C Verifiable Credentials through a simulated eKYC flow.
  • Generate and parse EMVCo-compliant merchant QR codes following the same TLV standard used by UPI, PromptPay, and PayNow globally.
  • Implement credential-gated payment authorization that cryptographically verifies a customer's identity before processing any transaction.
  • Secret Mission: Added credential expiry and revocation so the bank can invalidate a customer's KYC credential after issuance.

Ready to quiz yourself?