For the complete documentation index, see llms.txt. This page is also available as Markdown.

Creating a Verification Endpoint

What We Are Doing:

  • Adding a backend route (e.g., /kyc-authorize) that asks the Verifier to create an authorization request carrying our "kyc" presentation definition, and returns its QR data to the frontend.

  • Displaying the verification QR code so the user can scan it with the Empe DID Wallet.

  • Using SSE (Server-Sent Events) to receive real-time updates as the verification session changes state — through a small backend proxy that adds the Bearer token.

  • Exchanging the verified session for our app's own session token, so the protected dashboard can open.

Why: To start a verification, our app asks the Verifier to create an OpenID4VP authorization request. The Verifier returns an openid4vp:// request URI (which we render as a QR code) and a verification session id. The user scans the QR with their wallet, the wallet presents the requested credential, and the Verifier checks it. We watch the session over SSE and, once it reaches the verified state, mint our app session token and redirect the user to a protected resource.

Steps: Add the following to src/verifier-routes.js. This route calls the Verifier service (not our app) with a Bearer token and returns the authorization request URI and session id to the frontend. It sends the kycPresentationDefinition we built on the previous page — that definition is what enforces the credential type and age >= 18:

import { Router } from "express";
import dotenv from "dotenv";
import { Readable } from "node:stream";
import {
  kycPresentationDefinition,
  onKycVerified,
} from "./verification-flows.js";

dotenv.config();

const router = Router();
const VERIFIER_BASE_URL = process.env.VERIFIER_BASE_URL;
const VERIFIER_ACCESS_TOKEN = process.env.VERIFIER_ACCESS_TOKEN;
const VERIFIER_ID = process.env.VERIFIER_ID; // the verifier id from the deploy step

router.post("/kyc-authorize", async (req, res) => {
  try {
    // Ask the Verifier to create an OpenID4VP authorization request.
    // The presentation definition asks the wallet for a KYCCredential SD-JWT
    // credential and enforces age >= 18. Note the version: a
    // presentationDefinition requires "v1.draft24" (the default "v1" only
    // works with dcqlQuery).
    const response = await fetch(
      `${VERIFIER_BASE_URL}/verifiers/${VERIFIER_ID}/authorization-requests`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${VERIFIER_ACCESS_TOKEN}`,
        },
        body: JSON.stringify({
          responseMode: "direct_post.jwt",
          version: "v1.draft24",
          presentationDefinition: kycPresentationDefinition,
        }),
      }
    );

    const data = await response.json();
    // data = { authorizationRequestUri, verificationSessionId, authorizationRequestId?, expiresAt? }
    res.status(200).json(data);
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: "Internal server error" });
  }
});

export default router;

Request Body (sent to POST /verifiers/:verifierId/authorization-requests)

  • presentationDefinition — the "kyc" definition from the previous page: it pins the credential type via vct and requires the disclosed age to be at least 18. Provide either presentationDefinition or dcqlQuery — exactly one is required. (A DCQL query can request disclosure of age but cannot express age >= 18; if you use DCQL, your app must check the disclosed value itself.)

  • responseMode (optional) — how the wallet returns its response: direct_post, direct_post.jwt, dc_api, or dc_api.jwt. Defaults to direct_post.jwt.

  • version (optional) — OpenID4VP draft to use: v1, v1.draft21, or v1.draft24. Defaults to v1, which only works with dcqlQuery — a presentationDefinition requires v1.draft24 (or v1.draft21).

Response Body

  • authorizationRequestUri — the openid4vp:// request URI to encode as a QR code.

  • verificationSessionId — the session id we'll subscribe to for live updates.

  • authorizationRequestId (optional) — identifier of the hosted request.

  • expiresAt (optional) — when the request expires (ISO 8601).

If you prefer the Verifier to render the QR for you, call POST /verifiers/:verifierId/authorization-requests/qr-code with the same body. It returns a PNG image, and the request URI and session id come back in the X-Authorization-Request-Uri and X-Verification-Session-Id response headers.

Proxying the session event stream

The Verifier's session-events endpoint (GET /verification-sessions/:sessionId/events) requires a Bearer token like every other non-public route, and a browser EventSource cannot attach an Authorization header. So the frontend watches the stream through our own backend: a small proxy route that forwards the events and adds the token server-side. Add it to src/verifier-routes.js:

The proxied stream emits exactly what the Verifier sends: session events carrying the session object, plus a ping heartbeat every 15 seconds that keeps the connection alive.

Exchanging the verified session for an app token

Once the session reaches ResponseVerified, the frontend needs the app session token that the protected dashboard checks — minted by the onKycVerified helper from Setting Up the Verification Flow. Add a route that re-reads the session server-side (so a client cannot fake the state) and returns the token:

Mount the router

The routes above live in src/verifier-routes.js, but our Express app doesn't serve them yet. Update src/index.js to import and mount the router under the /verifier prefix, next to the issuer router from Issuing Credentials:

Frontend: button, QR code and SSE

The frontend gets the authorizationRequestUri and verificationSessionId from our route, renders the URI as a QR code, then opens an SSE connection to watch the verification session. In public/index.html, at the bottom of body, add a button to start verification:

The session moves through a small set of states you can react to:

  • RequestCreated — the authorization request was created and is waiting to be scanned.

  • RequestUriRetrieved — the wallet fetched the request and is preparing a presentation.

  • ResponseVerified — the wallet's presentation passed all checks. This is the success state.

  • Error — verification failed; errorMessage describes the problem.

Each SSE session event contains the full session object, for example:

Auth note: The frontend never talks to the Verifier directly — the EventSource points at our backend proxy (/verifier/kyc-sessions/:sessionId/events), which holds the VERIFIER_ACCESS_TOKEN and forwards the stream. This works against any deployment. (Only against a Verifier running locally with AUTH_DISABLED=true could a page open EventSource on VERIFIER_BASE_URL directly, since a browser EventSource cannot attach an Authorization header.)

With this in place, clicking "Authorize with KYC Credential" starts the verification flow: the user scans the QR, presents their credential, and once the session reaches ResponseVerified the frontend stores the app session token and is redirected to /authorization/dashboard. In the next step we'll build that protected dashboard.

Last updated