> ## Documentation Index
> Fetch the complete documentation index at: https://docs.air3.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Issue a KYC Credential on a Backend Event

> Wire a KYC-complete webhook to AIR credential issuance so signed, encrypted verifiable credentials land in users' AIR Accounts with no active session required.

This recipe shows how to issue a verifiable KYC credential the moment your identity provider confirms a user, using on-demand issuance. 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; the user's email is sent separately to `initialize-user`.
3. A call to `initialize-user` to resolve the recipient's DID and public key.
4. Issuance: build the VC, sign it with issuer keys, encrypt it to the holder, and store it in DStorage.

## Prerequisites

* A published issuance program with a schema that includes KYC fields (e.g. `kycVerified`, `kycLevel`, `verifiedAt`)
* [Partner JWT](/airkit/usage/partner-authentication) signing configured (RS256 or ES256)
* Issuer signing keys available to your backend

## 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.

```js theme={null}
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, storagePath: result.storagePath });
  } catch (err) {
    console.error("Issuance failed:", err.message);
    res.status(500).json({ error: err.message });
  }
});
```

## Step 2: Sign a Partner JWT

Generate a short-lived JWT with `scope: "issue"`. The JWT does not carry an `email` claim — the recipient is identified in the `initialize-user` call below.

<Warning>
  The recipient's email (passed to `initialize-user`) is the routing key that determines which AIR Account the credential lands in. Resolve it from the triggering event, and never reuse a partner, service, admin, or static email across recipients — every credential issued against that email lands in the same account.
</Warning>

```js theme={null}
const jwt = require("jsonwebtoken");
const fs = require("fs");

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

function getPartnerJwt() {
  const now = Math.floor(Date.now() / 1000);
  return jwt.sign(
    {
      partnerId: process.env.PARTNER_ID,
      scope: "issue",
      // No email claim — the recipient is passed to initialize-user, not the JWT
      iat: now,
      exp: now + 5 * 60,
    },
    privateKey,
    {
      algorithm: "RS256",
      header: { kid: process.env.KEY_ID, typ: "JWT" },
    }
  );
}
```

## Step 3: Resolve the user and issue

Resolve the recipient's AIR Account with `initialize-user`, then build, sign, encrypt, and store the credential. `buildVc`, `signVc`, and `encryptToHolder` are issuer-controlled helpers backed by your signing keys.

```js theme={null}
const { buildVc, signVc, encryptToHolder } = require("./lib/credential");

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

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

  // 1. Resolve or create the recipient's AIR Account
  const initRes = await fetch(`${BASE_URL}/auth/initialize-user`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "x-partner-auth": token },
    body: JSON.stringify({ email: recipientEmail }),
  });
  if (!initRes.ok) throw new Error(`initialize-user failed: ${initRes.status}`);
  const { did, publicKey } = await initRes.json();

  // 2. Build, sign, and encrypt the credential (issuer-controlled)
  const schemaId = process.env.CREDENTIAL_ID;
  const vc = buildVc({
    holderDid: did,
    schemaId,
    credentialSubject: {
      kycVerified: true,
      kycLevel: kycData.kycLevel,
      verifiedAt: kycData.verifiedAt || new Date().toISOString(),
    },
  });
  const signed = signVc(vc); // BJJ_SIG_2021, issuer-controlled key
  const encrypted = encryptToHolder(signed, publicKey);

  // 3. Store the encrypted envelope in DStorage
  const storeRes = await fetch(`${BASE_URL}/dstorage/vcs`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "x-partner-auth": token },
    body: JSON.stringify({
      holderDid: did,
      schemaId,
      expiresAt: vc.expirationDate,
      data: encrypted.encryptedData,
      iv: encrypted.iv,
      authTag: encrypted.authTag,
      encryptedKey: encrypted.dataEncPublicKey,
      externalId: vc.id,
    }),
  });
  if (!storeRes.ok) throw new Error(`dstorage/vcs failed: ${storeRes.status}`);
  return storeRes.json(); // { storagePath }
}
```

Issuance typically completes in \~1–4 seconds. Record the returned `storagePath` as your issuance result; the credential is immediately available for the holder to present at any verifier.

## Next steps

* [Issuance API Reference](/airkit/usage/credential/issuance-api) for full endpoint details
* [Schema Creation](/airkit/usage/credential/schema-creation) to define your KYC credential schema
* [Credential Verification](/airkit/usage/credential/verify) to let verifiers check the credential
