Skip to main content
This recipe shows how to issue a verifiable KYC credential the moment your identity provider confirms a user, using the Issue on Behalf API. The user never needs to be in an active session.

What you’ll build

  1. A webhook handler that fires when your KYC provider confirms a user.
  2. A Partner JWT signed with the issue scope targeting that user’s email.
  3. An Issue on Behalf API call that queues the credential for on-chain issuance.
  4. A status poller that confirms the credential is ONCHAIN.

Prerequisites

  • Issue on Behalf enabled for your partner account
  • A published issuance program with a schema that includes KYC fields (e.g. kycVerified, kycLevel, verifiedAt)
  • Partner JWT signing configured (RS256 or ES256)

Step 1: Handle the KYC webhook

When your KYC provider (Sumsub, Onfido, Jumio, etc.) sends a verification-complete callback, extract the user email and verification result.
const express = require("express");
const app = express();
app.use(express.json());

app.post("/webhooks/kyc-complete", async (req, res) => {
  const { userEmail, kycLevel, verifiedAt } = req.body;

  if (!userEmail) return res.status(400).json({ error: "Missing userEmail" });

  try {
    const result = await issueKycCredential(userEmail, { kycLevel, verifiedAt });
    res.json({ success: true, coreClaimHash: result.coreClaimHash });
  } catch (err) {
    console.error("Issue on Behalf failed:", err.message);
    res.status(500).json({ error: err.message });
  }
});

Step 2: Sign a Partner JWT

Generate a short-lived JWT with scope: "issue" and the target user’s email.
const jwt = require("jsonwebtoken");
const fs = require("fs");

const privateKey = fs.readFileSync("path/to/private.key");

function getPartnerJwt(email) {
  const now = Math.floor(Date.now() / 1000);
  return jwt.sign(
    {
      partnerId: process.env.PARTNER_ID,
      scope: "issue",
      email,
      iat: now,
      exp: now + 5 * 60,
    },
    privateKey,
    {
      algorithm: "RS256",
      header: { kid: process.env.KEY_ID, typ: "JWT" },
    }
  );
}

Step 3: Call Issue on Behalf

const BASE_URL =
  process.env.NODE_ENV === "production"
    ? "https://api.mocachain.org/v1"
    : "https://api.sandbox.mocachain.org/v1";

async function issueKycCredential(userEmail, kycData) {
  const token = getPartnerJwt(userEmail);

  const res = await fetch(`${BASE_URL}/credentials/issue-on-behalf`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-partner-auth": token,
    },
    body: JSON.stringify({
      issuerDid: process.env.ISSUER_DID,
      credentialId: process.env.CREDENTIAL_ID,
      credentialSubject: {
        kycVerified: true,
        kycLevel: kycData.kycLevel,
        verifiedAt: kycData.verifiedAt || new Date().toISOString(),
      },
      onDuplicate: "revoke",
    }),
  });

  if (!res.ok) throw new Error(`Issue failed: ${res.status} ${await res.text()}`);
  return res.json();
}

Step 4: Poll for on-chain confirmation

Issuance is asynchronous. Poll the status endpoint until vcStatus is ONCHAIN.
async function waitForOnchain(userEmail, coreClaimHash, maxAttempts = 10) {
  for (let i = 0; i < maxAttempts; i++) {
    const token = getPartnerJwt(userEmail);
    const res = await fetch(
      `${BASE_URL}/credentials/status?coreClaimHash=${encodeURIComponent(coreClaimHash)}`,
      { headers: { "x-partner-auth": token } }
    );
    const data = await res.json();
    if (data.vcStatus === "ONCHAIN") return data;
    await new Promise((r) => setTimeout(r, Math.pow(2, i) * 1000));
  }
  throw new Error("Timed out waiting for ONCHAIN status");
}

Full flow

app.post("/webhooks/kyc-complete", async (req, res) => {
  const { userEmail, kycLevel } = req.body;

  const issued = await issueKycCredential(userEmail, {
    kycLevel,
    verifiedAt: new Date().toISOString(),
  });

  const confirmed = await waitForOnchain(userEmail, issued.coreClaimHash);
  console.log(`Credential ${confirmed.vcId} is on-chain for ${userEmail}`);

  res.json({ success: true, vcId: confirmed.vcId });
});

Next steps