> ## 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: Issuing your first credential

> Run the AIR issuer service, register it with AIR, and issue your first SD-JWT credential to a logged-in user with AIR Kit.

This guide implements on-demand issuance with the user present. Your app calls
`issueCredential()` to open the AIR flow. The SDK handles login, credential
discovery, and user confirmation; AIR forwards issuance to your backend, which
authorizes the request, retrieves the data, creates and signs the credential,
encrypts it, and stores it in dStorage.

New issuance programs use the `SD_JWT_VC` proof type. This setup is self-serve
in both sandbox and production. To issue Iden3 (`BJJ_SIG_2021`) credentials
instead, see [Iden3 credentials](/products/identity/iden3-credentials).

<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
  [Direct issuance](/products/identity/issuing-credentials#direct-issuance).
</Info>

## Before you start

You need:

* A supported Node.js version
* An issuer backend implementing `available-vc` and `issue-vc`, reachable by AIR
  over HTTPS. You can host it or use an HTTPS tunnel during development.
* An AIR Developer Dashboard account and Partner ID
* A server endpoint that signs Partner JWTs, and a public HTTPS JWKS endpoint (you set both up in Step 6)
* A web app whose origin is registered in the Dashboard

**A database is optional in sandbox.**
For development and testing, credential data can come from an existing API or service, or you can use static sample data.
For production, however, if you need to support features such as **credential revocation, issuance history, and token status lists,**
especially at scale with many users, you will need to persist the relevant data in your own database.

Regardless of whether you use a database, encrypted credentials are stored in dStorage.

The backend and JWKS endpoint can share a host. See [Issuer backend hosting](/products/identity/backend-hosting).

<Warning>
  Choose a stable public origin for the issuer service before you issue. The issuer DID is `did:web:<host of ISSUER_ORIGIN>`, so changing the host later changes your issuer identity.
</Warning>

## What you will build

By the end of this guide, you will have:

* A public issuer backend with `POST /available-vc`, `POST /issue-vc`, a
  revocation-status endpoint, and a `did:web` document
* 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`

<CardGroup cols={2}>
  <Card title="AIR issuer service (SD-JWT)" icon="github" href="https://github.com/MocaNetwork/air-issuer-service/tree/main">
    Issuer endpoints, schema logic, SD-JWT signing, encryption, revocation, and dStorage integration.
  </Card>
</CardGroup>

## How SDK issuance works

1. Your app starts `issueCredential()` with the issuer and issuance program.
2. The user logs in to AIR if needed.
3. The SDK requests available credentials through AIR. AIR calls the registered
   issuer backend's `POST /available-vc` and returns encrypted previews.
4. The SDK decrypts and displays the previews. The user selects a credential and
   confirms issuance.
5. The SDK requests issuance through AIR, which calls the issuer's `POST /issue-vc`
   with the resolved holder identity, encryption key, holder signing key, and schema.
6. The issuer authorizes the request, loads authoritative claims, creates and
   signs the SD-JWT VC, encrypts it, and stores it in dStorage.
7. The SDK reports completion to your app. If the user declines, issuance stops.

The browser does not need the issuer API key or a separate request to fetch claims.
See [Architecture and data flow](/technicals/architecture) for the sequence diagram.

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 partner secrets

Clone the SD-JWT branch of the [AIR issuer service](https://github.com/MocaNetwork/air-issuer-service/tree/main),
or your fork of it, then install its dependencies:

```bash theme={null}
git clone -b main https://github.com/MocaNetwork/air-issuer-service.git
cd air-issuer-service
npm i
```

Check the branch README and `.env.example` when you upgrade.

Generate separate API keys for AIR-to-issuer calls and admin calls:

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

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 three places:

* The issuer backend signs the SD-JWT credentials and its requests to dStorage
  with the private key.
* Your web server signs short-lived AIR Kit Partner JWTs with the private key.
* The public key is published through your JWKS and the issuer's `did:web`
  document, so AIR and verifiers can check both.

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

## Step 2: Configure and start the issuer backend

Create the issuer backend environment file:

```bash theme={null}
NODE_ENV=sandbox

# Optional in sandbox; required for revocation, history, and the status list at scale
# DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/issuer-backend

# Public origin of this issuer service, without a trailing slash.
# The issuer DID is did:web:<host of this origin>.
ISSUER_ORIGIN=https://issuer.example.com

# Partner identity and signing
PARTNER_ID=<dashboard-partner-id>
PARTNER_PRIVATE_KEY_KID=<kid-in-your-jwks>
PARTNER_PRIVATE_KEY_ALG=RS256
PARTNER_PRIVATE_KEY_DER=<pkcs8-private-key-body-without-pem-markers>
PARTNER_JWKS=<public-JWKS-JSON-containing-the-matching-kid>

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

# Optional
# AIR_API_ORIGIN and MOCA_CHAIN_API_ORIGIN default per NODE_ENV
# SD_JWT_TSL_PARTITION_SIZE=80000   # enables the token status list; see Revoke credentials
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.

`PARTNER_JWKS` contains public keys only, matching your Partner JWT signing key
and `kid`. Generate and publish the document using [JWKS endpoint setup](/get-started/authentication/jwks-endpoint).
The service serves these keys in its `did:web` document and issuer metadata.

If you set `DATABASE_URL` (this issuer service uses PostgreSQL), apply migrations before the first start:

```bash theme={null}
npx mikro-orm migration:up
```

Start the service:

```bash theme={null}
npm run start:dev
```

Once `ISSUER_ORIGIN` is reachable over HTTPS, confirm the public identity routes:

```bash theme={null}
curl https://issuer.example.com/.well-known/did.json
curl https://issuer.example.com/.well-known/jwt-vc-issuer
```

`did.json` returns the issuer DID (`did:web:issuer.example.com`) with your
public keys as verification methods. Credentials are signed with header
`kid` set to `<issuer DID>#<PARTNER_PRIVATE_KEY_KID>`.

<Note>
  The credential `iss` is the `did:web` DID, while `/.well-known/jwt-vc-issuer`
  identifies the HTTPS origin. AIR verification resolves the DID document.
  Some external verifiers expect these identifiers to match exactly; test with
  the verifier you plan to support.
</Note>

## Step 3: Register the issuer with AIR

Expose the issuer backend over public HTTPS. Register the issuer under
**Dashboard → Issuer → Settings** in the sandbox or production Dashboard, then
configure its endpoint URLs:

| Item | Value |
| - | - |
| Issuer DID | `did:web:<host of ISSUER_ORIGIN>` |
| Signature type | `SD_JWT_VC` |
| API key | The backend `API_KEY` |
| Partner ID | Your Dashboard Partner ID |
| Available credentials URL (`availableVcApiUrl`) | `https://issuer.example.com/available-vc` |
| Issuance URL (`issueVcApiUrl`) | `https://issuer.example.com/issue-vc` |
| Revocation status URL (`revocationStatusApiUrl`) | Your issuer's `GET /revocation-status/:nonce` route |

After registration:

1. Confirm the Issuer DID in the Dashboard matches the `id` in your
   `/.well-known/did.json`.
2. Confirm AIR can reach both registered issuer endpoints over HTTPS.
3. Keep `API_KEY` identical to the value you registered with AIR.

AIR calls these issuer endpoints with `x-api-key`. Keep the key server-side;
your browser calls AIR Kit with a short-lived Partner JWT. A missing or incorrect
issuer API key causes the backend to return `403`.

If you enable CORS on the issuer or a proxy in front of it, allow `*.air3.com`.

## Step 4: Create schema for credential issuance

After registering 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 and record its schema ID.
5. Go to **Issuer → Programs** and create an issuance program using the
   published schema.
6. Select `SD_JWT_VC` as the signature type.
7. Record the issuance program ID.

For SD-JWT credentials, the schema ID is the credential type (`vct`) unless
your schema class sets its own. For schema design rules and supported field
types, see [Schema creation](/products/identity/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 your backend will issue and marks which claims the holder
can disclose selectively.

```ts src/issuer/sd-jwt-vc-schemas/schema_<SCHEMA_ID>.ts theme={null}
import { DisclosureFrame } from "@sd-jwt/core";
import { BaseSchema } from "./base-schema";

type Claim = {
  historical_amount: string;
};

class Schema_<SCHEMA_ID> extends BaseSchema<Claim> {
  public readonly schemaId = "<SCHEMA_ID>";
  public readonly vct = undefined; // defaults to schemaId
  public readonly ["vct#integrity"] = undefined;
  public readonly disclosureFrame: DisclosureFrame<Claim> = {
    _sd: ["historical_amount"], // claims the holder can disclose selectively
  };
  public readonly expirySec = 30 * 24 * 60 * 60;

  async generateCredentialData(userId: string) {
    // Load attributes from your database or API using userId
    return {
      credentialSubject: {
        historical_amount: "1000",
      },
      expiration: Math.floor(Date.now() / 1000) + this.expirySec,
    };
  }
}

export default new Schema_<SCHEMA_ID>();
```

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

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

const schemas: BaseSchema<any>[] = [HistoricalAmountSchema];

export default schemas;
```

Follow these rules:

* Claim keys and JavaScript types must match the published Dashboard schema.
* Do not return reserved claims from `generateCredentialData`: `id`, `nonce`,
  `vct`, `sub`, `exp`, `cnf`, `iss`, `iat`, or `status`. The service sets them.
* `expirySec` sets the credential's `exp`. The `expiration` you return is only
  used for the preview.
* List a claim in `disclosureFrame._sd` only if verifiers may request it on
  its own. Claims outside `_sd` are always visible in a presentation.
* 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.

### Issuer endpoint contract

AIR calls both issuer endpoints during on-demand issuance: `POST /available-vc`
provides encrypted previews, and `POST /issue-vc` performs issuance after the user
confirms. The issuer validates eligibility using the AIR-resolved partner user
identifier and retrieves claims from its own data source.

<Accordion title="Issuer endpoint requests and responses">
  `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": "SD_JWT_VC"
  }
  ```

  It returns encrypted previews:

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

  `POST /issue-vc` accepts the selected schema and the holder's signing key:

  ```json theme={null}
  {
    "holderDID": "did:air:...",
    "pubKey": "0x...",
    "userId": "<partner-primary-id>",
    "schemaId": "<SCHEMA_ID>",
    "signingKey": { "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." } },
    "proofType": "SD_JWT_VC"
  }
  ```

  On success, the issuer backend:

  1. Generates the credential data.
  2. Sets `sub` to `holderDID` and, when `signingKey` is present, `cnf` to the
     holder's public key. This binds the credential to the holder.
  3. Signs the SD-JWT VC with your partner key under the `did:web` issuer DID.
  4. Encrypts the credential to `pubKey`.
  5. Persists the issuance record when database persistence is enabled.
  6. Uploads the encrypted credential to dStorage.
  7. Returns HTTP `201` with an empty response body.

  AIR sends `x-api-key: <API_KEY>` to both endpoints. This issuer HTTP response is
  distinct from the SDK result returned to your app.
</Accordion>

## Step 6: Install AIR Kit and set up Partner authentication

Install AIR Kit in your web application:

```bash theme={null}
npm i @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

Issuance fails until AIR can fetch your JWKS. Follow [JWKS endpoint](/get-started/authentication/jwks-endpoint),
then check that the registered URL returns the `kid` your Partner JWT will use.

### Sign the Partner JWT

Implement the [Next.js Partner JWT
endpoint](/get-started/authentication/sdk-auth#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

The frontend examples use Next.js. Create a singleton AIR service and a helper
for fetching the Partner JWT. Call these helpers from a browser event handler.

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

let servicePromise: Promise<AirService> | undefined;

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

    const service = new AirService({ partnerId });
    await service.init({ buildEnv: BUILD_ENV.SANDBOX });
    return service;
  })().catch((error) => {
    servicePromise = undefined;
    throw error;
  });
  return servicePromise;
}

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 issuerDid = process.env.NEXT_PUBLIC_ISSUER_DID;
  const credentialId = process.env.NEXT_PUBLIC_ISSUE_PROGRAM_ID;
  if (!issuerDid || !credentialId) {
    throw new Error("Issuer DID and issuance program ID are required");
  }

  const air = await getInitializedAirService();

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

  const authToken = await fetchPartnerJwt();

  return air.issueCredential({
    authToken,
    issuerDid,
    credentialId,
    credentialSubject: {},
  });
}
```

Pass `{}` as `credentialSubject`: the AIR credential UI fetches encrypted
previews through AIR and the issuer backend supplies the authoritative claims.

The SDK promise resolves on success and rejects on failure. It does not return the VC or a dStorage path. Handle the SDK result separately from backend storage records.

## Step 8: Test end to end

Add your web app's origin under **Account → Domains**. 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.

When database persistence is enabled, you can also 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 .
```

Without database persistence, an empty history is not a failed issuance.
Confirm that the SDK completes successfully and that the holder's credential
can be discovered and used in the verification flow.

## 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 token contains `scope: "issue"` and has not expired. For JWKS and
`kid` checks, see [JWKS endpoint troubleshooting](/get-started/authentication/jwks-endpoint#troubleshooting).

### No credential preview appears

Confirm AIR can call the registered `POST /available-vc` endpoint and that the
backend returns an encrypted preview for the logged-in holder. Check the
program's issuer DID, schema, proof type, and the user's eligibility.

### Schema validation fails

Confirm that:

* `schemaId` matches the published Dashboard schema.
* Claim names and types match the schema.
* Your data does not include reserved claims such as `id`, `sub`, `vct`, or `cnf`.

### Issuer backend is unreachable

Check that the service is running, both issuer endpoint URLs are registered,
and your HTTPS domain or tunnel is reachable by AIR. If a development tunnel URL
changes, update `ISSUER_ORIGIN` and the registered URLs. This also changes the
issuer DID, so only do it before you issue credentials you want to keep.

## Next steps

* [Credential verification quickstart](/get-started/quickstarts/verify-credentials)
* [Issuing credentials](/products/identity/issuing-credentials)
* [Revoke credentials](/products/identity/revocation)
* [Issuance API reference](/api-reference/issuance-api)
* [Identity & Credential troubleshooting](/help/identity-credential)
* [AIR Credential example](https://github.com/MocaNetwork/air-credential-example)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.