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

# Quickstart 2: Credential issuance

> Implement hosted credential issuance in AIR Kit using an issuer backend, Partner JWT authentication, and the credential issuance widget.

This guide implements interactive credential issuance from end to end. Your web
app asks your issuer backend for a claimable `credentialSubject`, then starts
the flow with AIR Kit via `issueCredential`. Your issuer backend remains
authoritative over the claims it produces.

New issuance programs should use the `BJJ_SIG_2021` proof type.

<Info>
  This guide covers user-initiated SDK issuance through a self-hosted issuer
  backend. To issue from a backend event with no user session, see
  [On-demand issuance](/airkit/usage/credential/issuing-credentials#on-demand-issuance).
</Info>

## Before you start

You need:

* Node.js 18 or later
* A database and a hosted issuer backend (or local Postgres plus an HTTPS
  tunnel). For the fastest free sandbox path, see
  [Host an issuer backend and database](/airkit/usage/backend-hosting).
* An AIR Developer Dashboard account and Partner ID
* The hosted AIR issuer service starter supplied during issuer onboarding
* Two public HTTPS origins, or HTTPS tunnels, for your issuer backend and web
  app
* Contact to the AIR team for issuer activation

### Critical setup checkpoints

These requirements block successful issuance if skipped:

* **Set the issuer seed first.** Replace the `SEED` placeholder before
  generating the issuer DID. Changing the seed later produces a different DID.
* **Complete AIR activation.** AIR must register your Issuer DID, API key,
  and Partner ID before credential services are enabled for your account.
* **Publish and register your JWKS.** Host it at a public HTTPS URL, register
  the exact URL in the Developer Dashboard, and sign Partner JWTs with a
  matching `kid`. See [JWKS endpoint setup](/airkit/usage/jwks-setup).

## What you will build

By the end of this guide, you will have:

* A public issuer backend with `POST /available-vc`, `POST /issue-vc`, and
  credential-status endpoints
* An issuer DID derived from a secret seed
* A Dashboard schema and issuance program
* A schema class that produces the claims your backend signs
* A public JWKS endpoint and a server-only Partner JWT endpoint
* A web flow that logs in with AIR Kit and calls `issueCredential`

The public examples below are useful implementation companions, but the steps
on this page contain the required integration contract:

<CardGroup cols={2}>
  <Card title="Credential issuance service" icon="github" href="https://github.com/MocaNetwork/air-issuer-service">
    A production-oriented AIR issuer backend.
  </Card>

  <Card title="AIR E2E Example" icon="github" href="https://github.com/MocaNetwork/air-issuer-service-simulator">
    A monorepo that includes both the backend and frontend implementations, showcasing the complete end-to-end integration as a single, cohesive example.
  </Card>
</CardGroup>

## How SDK issuance works

```mermaid theme={null}
sequenceDiagram
    autonumber
    actor Holder
    participant App as Your web app
    participant Backend as Your issuer backend
    participant SDK as AIR Kit
    Holder->>App: Start issuance
    App->>Backend: Request claimable credential
    Backend->>Backend: POST /available-vc
    Backend-->>App: credentialSubject
    App->>SDK: issueCredential({ credentialSubject })
    SDK-->>App: Issuance complete
```

Your frontend calls your issuer backend to obtain the `credentialSubject`. The
backend uses `POST /available-vc` (or equivalent logic) to derive the claims.
The frontend then passes that subject into `air.issueCredential`. Your backend
remains authoritative over what it produces; do not let the browser invent claim
values.

The issuance program ID is an AIR Kit and Dashboard identifier. Your issuer
backend works with a `schemaId`, not the program ID.

## Step 1: Generate issuer and partner secrets

Generate a 32-byte issuer seed and separate API keys:

```bash theme={null}
echo "SEED=0x$(openssl rand -hex 32)"
echo "API_KEY=$(openssl rand -hex 32)"
echo "ADMIN_API_KEY=$(openssl rand -hex 32)"
```

`SEED` deterministically controls your issuer DID. Store it in a secret
manager, back it up, and do not rotate it for an existing issuer.

Generate an RSA key pair for Partner JWT signing:

```bash theme={null}
openssl genpkey \
  -algorithm RSA \
  -pkeyopt rsa_keygen_bits:2048 \
  -out partner-private.pem

openssl pkey \
  -in partner-private.pem \
  -pubout \
  -out partner-public.pem
```

You will use the same key pair in two places:

* The issuer backend signs requests to DStorage with the private key.
* Your web server signs short-lived AIR Kit Partner JWTs with the private key
  and exposes the public key through JWKS.

Never expose the private key or issuer seed through a `NEXT_PUBLIC_*`
environment variable.

## Step 2: Configure and start the issuer backend

Create the issuer backend environment file:

```bash theme={null}
# PostgreSQL
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/issuer-backend

# Public origin of this issuer service, without a trailing slash
ISSUER_ORIGIN=https://issuer.example.com

# AIR and Moca Chain sandbox services
AIR_API_ORIGIN=https://air.api.sandbox.air3.com
MOCA_CHAIN_API_ORIGIN=https://api.sandbox.mocachain.org

# Issuer identity
SEED=0x<64-hex-characters>
IDEN3_METHOD=air
IDEN3_BLOCKCHAIN=id
IDEN3_NETWORK_ID=testnet

# Partner identity and signing
PARTNER_ID=<dashboard-partner-id>
PARTNER_PRIVATE_KEY_KID=<dashboard-partner-id>
PARTNER_PRIVATE_KEY_ALG=RS256
PARTNER_PRIVATE_KEY_DER=<pkcs8-private-key-body-without-pem-markers>
SD_JWT_HASH_ALG=sha-256

# AIR-to-issuer and operator authentication
API_KEY=<generated-api-key>
ADMIN_API_KEY=<generated-admin-api-key>

# Optional
PORT=3000
```

`PARTNER_PRIVATE_KEY_DER` is the base64 body between the `BEGIN PRIVATE KEY`
and `END PRIVATE KEY` lines in `partner-private.pem`. It must be PKCS#8.

Install dependencies, apply the database migrations, and start the service:

Check that the database, migrations, and issuer identity are ready:

```bash theme={null}
curl -s http://localhost:3000/ready | jq .
```

A ready service returns HTTP `200`:

```json theme={null}
{
  "status": "ready",
  "checks": {
    "database": { "status": "ok" },
    "migrations": { "status": "ok", "pending": [] },
    "issuer": { "status": "ok", "did": "did:air:..." }
  }
}
```

Retrieve the issuer DID:

```bash theme={null}
curl -s http://localhost:3000/.well-known/issuer-did | jq .
```

The DID is derived from `SEED` and the three `IDEN3_*` values:

```json theme={null}
{
  "did": "did:air:...",
  "issuer": "https://issuer.example.com"
}
```

`ISSUER_ORIGIN` must be the public HTTPS origin that serves
`/credential-status/:nonce`. This URL is embedded in every issued credential.

## Step 3: Register the issuer with AIR

Expose the issuer backend over public HTTPS, then provide the Moca Network /
AIR team with:

| Item       | Value                                        |
| ---------- | -------------------------------------------- |
| Issuer DID | The `did` from `GET /.well-known/issuer-did` |
| API key    | The backend `API_KEY`                        |
| Partner ID | Your Dashboard Partner ID                    |

For example:

```text theme={null}
Issuer DID: did:air:...
Partner ID: 00000000-0000-0000-0000-000000000000
API key: <generated-api-key>
```

After AIR activates the issuer:

1. Confirm the Issuer DID in the Dashboard matches
   `GET /.well-known/issuer-did`.
2. Confirm `GET /ready` returns `"status": "ready"` through the public URL.
3. Keep `API_KEY` identical to the value you registered with AIR.

Your frontend and internal jobs call your issuer endpoints with `x-api-key`.
If the key is missing or incorrect, the backend returns `403`.

## Step 4: Create schema for credential issuance

After AIR activates your issuer, create the schema and issuance program:

1. Open the <a href="https://developers.sandbox.air3.com" target="_blank" rel="noreferrer">Sandbox Developer Dashboard</a>.
2. Go to **Issuer → Schemas**.
3. Create a schema with a string attribute named `historical_amount`.
4. Publish the schema.
5. Record its schema ID, type, schema JSON URL, and JSON-LD context URL.
6. Go to **Issuer → Programs** and create an issuance program using the
   published schema.
7. Select `BJJ_SIG_2021` as the signature type.
8. Record the issuance program ID.

For schema design rules and supported field types, see [Schema
creation](/airkit/usage/credential/schema-creation).

## Step 5: Implement the credential data

Create one schema class for each Dashboard schema. The class maps a Dashboard
schema to the claims and expiration your backend will issue.

```ts src/issuer/schemas/schema-<SCHEMA_ID>.ts theme={null}
import { BaseSchema } from "./base-schema";

const EXPIRY_SEC = 30 * 24 * 60 * 60;

export default class HistoricalAmountSchema extends BaseSchema {
  public readonly schemaId = "<SCHEMA_ID>";
  public readonly schemaType = "<SCHEMA_TYPE>";
  public readonly schemaUrl = "<SCHEMA_JSON_URL>";
  public readonly schemaContextUrl = "<JSON_LD_CONTEXT_URL>";

  generateCredentialData() {
    return Promise.resolve({
      credentialSubject: {
        historical_amount: "1000",
      },
      expiration: Math.floor(Date.now() / 1000) + EXPIRY_SEC,
    });
  }
}
```

Register the class so `/available-vc` and `/issue-vc` can resolve its
`schemaId`:

```ts src/issuer/schemas/index.ts theme={null}
import { BaseSchema } from "./base-schema";
import HistoricalAmountSchema from "./schema-<SCHEMA_ID>";

const schemas: BaseSchema[] = [new HistoricalAmountSchema()];

export default schemas;
```

Follow these rules:

* Claim keys and JavaScript types must match the published Dashboard schema.
* Return expiration as Unix time in seconds.
* Do not add `credentialSubject.id`. The issuer service sets it to the holder
  DID after adding your claims.
* Keep the backend authoritative. Do not sign claim values supplied by the
  browser without validating them against your own data.
* When you replace the static example with a database or API lookup, use the
  partner user ID at the issuer boundary for eligibility and data
  retrieval.

<Warning>
  Setting `credentialSubject.id` to an email address or partner user ID causes
  schema validation to fail with `must match format "uri"`. The holder DID must
  remain the credential subject ID.
</Warning>

### Issuer endpoint contract

Your frontend asks your issuer backend for a claimable credential. The backend
uses `POST /available-vc` (or equivalent logic) to derive the
`credentialSubject` that the frontend passes into `air.issueCredential`.
`POST /issue-vc` is used for [On-demand
issuance](/airkit/usage/credential/issuing-credentials#on-demand-issuance)
when the end user does not interact with AIR.

`POST /available-vc` accepts the holder identity and optional filters:

```json theme={null}
{
  "holderDID": "did:air:...",
  "pubKey": "0x...",
  "userId": "<partner-primary-id>",
  "schemaId": "<optional-schema-id>",
  "proofType": "BJJ_SIG_2021"
}
```

It returns encrypted previews:

```json theme={null}
{
  "data": [
    {
      "holderDID": "did:air:...",
      "schemaId": "<SCHEMA_ID>",
      "credentialSubject": {
        "encryptedData": "...",
        "iv": "...",
        "authTag": "...",
        "dataEncPublicKey": "..."
      },
      "proofType": "BJJ_SIG_2021"
    }
  ]
}
```

`POST /issue-vc` accepts the selected schema:

```json theme={null}
{
  "holderDID": "did:air:...",
  "pubKey": "0x...",
  "userId": "<partner-primary-id>",
  "schemaId": "<SCHEMA_ID>",
  "proofType": "BJJ_SIG_2021"
}
```

On success, the issuer backend:

1. Generates the credential data.
2. Sets `credentialSubject.id` to `holderDID`.
3. Signs the W3C credential with the issuer identity derived from `SEED`.
4. Encrypts the complete credential to `pubKey`.
5. Persists the issuance record.
6. Uploads the encrypted credential to DStorage.
7. Returns HTTP `200` with an empty response body.

Both endpoints require `x-api-key: <API_KEY>`.

## Step 6: Configure Partner authentication

Install AIR Kit in your web application:

```bash theme={null}
pnpm add @mocanetwork/airkit
```

Add the browser-safe and server-only values to your web environment:

```bash theme={null}
# Browser-safe
NEXT_PUBLIC_PARTNER_ID=<dashboard-partner-id>
NEXT_PUBLIC_ISSUER_DID=<issuer-did>
NEXT_PUBLIC_ISSUE_PROGRAM_ID=<dashboard-program-id>
NEXT_PUBLIC_BUILD_ENV=sandbox

# Server-only
PARTNER_PUBLIC_KEY=<public-key-body-without-pem-markers>
PARTNER_PRIVATE_KEY=<pkcs8-private-key-body-without-pem-markers>
SIGNING_ALGORITHM=RS256
```

### Set up the JWKS endpoint

Complete [JWKS endpoint setup](/airkit/usage/jwks-setup), including deploying
the endpoint at a public HTTPS URL and registering that exact URL in the
Developer Dashboard. Before continuing, verify that the endpoint returns a
`keys` array containing the `kid` your Partner JWT will use.

### Sign the Partner JWT

Implement the [Next.js Partner JWT
endpoint](/airkit/usage/partner-authentication#nextjs-partner-jwt-endpoint).
The endpoint must generate a short-lived token on the server with
`scope: "issue"` and a `kid` that appears in your registered JWKS.

## Step 7: Initialize AIR Kit and issue the credential

Create a singleton AIR service and a helper for fetching the Partner JWT:

```ts lib/air.ts theme={null}
import {
  AirService,
  BUILD_ENV,
  type BUILD_ENV_TYPE,
} from "@mocanetwork/airkit";

let airService: AirService | null = null;

function resolveBuildEnv(value?: string): BUILD_ENV_TYPE {
  const normalized = (value ?? "sandbox").toLowerCase();
  return (
    Object.values(BUILD_ENV).find(
      (environment) => environment === normalized,
    ) ?? BUILD_ENV.SANDBOX
  );
}

export async function getInitializedAirService(): Promise<AirService> {
  const partnerId = process.env.NEXT_PUBLIC_PARTNER_ID;
  if (!partnerId) throw new Error("NEXT_PUBLIC_PARTNER_ID is required");

  airService ??= new AirService({ partnerId });

  if (!airService.isInitialized) {
    await airService.init({
      buildEnv: resolveBuildEnv(process.env.NEXT_PUBLIC_BUILD_ENV),
      enableLogging: true,
      preloadCredential: true,
      preloadWallet: false,
      skipRehydration: false,
    } as Parameters<AirService["init"]>[0]);
  }

  return airService;
}

export async function fetchPartnerJwt(): Promise<string> {
  const response = await fetch("/api/partner-jwt", { method: "POST" });
  const body = (await response.json()) as {
    token?: string;
    error?: string;
  };

  if (!response.ok || !body.token) {
    throw new Error(
      body.error ?? `Partner JWT request failed (${response.status})`,
    );
  }

  return body.token;
}
```

Log in before starting issuance, then call `issueCredential`:

```ts theme={null}
import { fetchPartnerJwt, getInitializedAirService } from "@/lib/air";

export async function issueHistoricalAmountCredential() {
  const air = await getInitializedAirService();

  if (!air.isLoggedIn) {
    await air.login();
  }

  const authToken = await fetchPartnerJwt();

  return air.issueCredential({
    authToken,
    issuerDid: process.env.NEXT_PUBLIC_ISSUER_DID!,
    credentialId: process.env.NEXT_PUBLIC_ISSUE_PROGRAM_ID!,
    credentialSubject: {
      historical_amount: "1000",
    },
  });
}
```

For this SDK flow, fetch `credentialSubject` from your issuer backend before
calling `issueCredential`. Your backend remains authoritative over the claims —
do not invent claim values in the browser without validating them against your
own data.

## Step 8: Complete Dashboard setup and test end to end

Complete the remaining Dashboard setup:

1. Add the web origin under **Dashboard → Domains**.
2. Confirm the Dashboard Issuer DID matches the backend DID.
3. Confirm the issuance program uses the same schema registered in the issuer
   backend.
4. Confirm the issuer backend `API_KEY` is configured and reachable from your
   web app's backend.

Start the issuer backend and web app, then:

1. Open the web app.
2. Log in through AIR Kit.
3. Start credential issuance.
4. Confirm the AIR Credential UI shows the available credential.
5. Approve issuance.
6. Confirm the app receives a successful `issueCredential` result.

Check the backend issuance history:

```bash theme={null}
curl -s \
  -H "x-admin-api-key: <ADMIN_API_KEY>" \
  "https://issuer.example.com/admin/issuance-history?page=1&limit=25" \
  | jq .
```

For on-demand issuance, the backend's `/issue-vc` response is intentionally
empty — a successful issuance is recorded in the issuer's history and DStorage
rather than returned as a credential in that HTTP response.

## Troubleshooting

### Issuer backend returns 403

The caller is not sending the expected `x-api-key`, or the value differs from the
backend `API_KEY`. Confirm the Partner ID, Issuer DID, and API key match what you
registered with AIR.

### AIR cannot validate the Partner JWT

Confirm:

* The JWKS URL is public HTTPS and returns `{ "keys": [...] }`.
* JWT `kid` exactly matches a JWKS key.
* JWT `alg` matches the JWKS `alg`.
* The private and public keys belong to the same key pair.
* The token contains `scope: "issue"` and has not expired.

See [JWKS endpoint setup](/airkit/usage/jwks-setup) for a complete diagnostic
checklist.

### Frontend cannot obtain credentialSubject

Confirm your web app calls your issuer backend (not AIR) to fetch the claimable
`credentialSubject`, and that `POST /available-vc` returns data for the logged-in
holder. Check that the issuance program points to the activated Issuer DID and
schema.

### Schema validation fails

Confirm that:

* `schemaId`, `schemaType`, and `schemaUrl` match the published Dashboard
  schema.
* Claim names and types match the schema.
* `expiration` uses Unix seconds.
* Your data does not include `credentialSubject.id`.

### Issuer backend is unreachable

## Next steps

* [Credential verification quickstart](/airkit/quickstart/verify-credentials)
* [Issuing credentials](/airkit/usage/credential/issuing-credentials)
* [Issuance API reference](/airkit/usage/credential/issuance-api)
* [Credential troubleshooting](/airkit/troubleshooting/credential-issues)
* [AIR Credential example](https://github.com/MocaNetwork/air-credential-example)
