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

# Issuance API Reference

> Reference for the AIR credential issuance APIs — resolve users, store encrypted VCs in DStorage, plus the available-vc and issue-vc issuer endpoints.

* Read concepts first: [Issuing Credentials](/airkit/usage/credential/issuing-credentials) and [Direct Issuance](/airkit/usage/credential/direct-issuance)
* For JWT/JWKS setup and signing examples, see [Partner Authentication](/airkit/usage/partner-authentication).

## Base URLs

| Environment | Base URL                                    |
| ----------- | ------------------------------------------- |
| Sandbox     | `https://api.sandbox.mocachain.org/v1`      |
| Production  | `https://mocachain-mainnet.api.air3.com/v1` |

<Note>
  The production base URL is for approved production partners on Moca Chain private Mainnet. See [Production mainnet access](/airkit/environments#production-mainnet-access) before requesting mainnet \$MOCA gas tokens.
</Note>

## Authentication

Direct AIR API requests (`initialize-user` and `dstorage/vcs`) authenticate via a **Partner JWT** in the `x-partner-auth` header, signed with `partnerId` and `scope: "issue"`. The JWT does **not** carry an `email` claim — the recipient is identified in the `initialize-user` request body. The header must include `kid` matching a key in your JWKS endpoint and `typ: "JWT"`. See [Partner Authentication](/airkit/usage/partner-authentication).

AIR calls an issuer-hosted `available-vc` or `issue-vc` endpoint with the shared issuer backend API key in the `x-api-key` header. Configure the same value in the issuer service (`API_KEY`) and AIR (`issuerBackendApiKey`).

## Issuance surfaces

There are two ways to issue, both of which sign with issuer-controlled keys and store an encrypted envelope in DStorage:

* **Script-based direct issuance** — your script calls `POST /auth/initialize-user` to resolve the user, builds/signs/encrypts the VC, then stores it with `POST /dstorage/vcs`.
* **Hosted Issuer Backend** — you host `available-vc` and `issue-vc`; AIR and the SDK call them to discover and trigger issuance on demand.

## 1) Initialize / resolve user

Resolve or create the recipient's AIR Account and return their DID and public key.

```
POST /auth/initialize-user
```

### Request headers

| Header           | Required | Value              |
| ---------------- | -------- | ------------------ |
| `Content-Type`   | Yes      | `application/json` |
| `x-partner-auth` | Yes      | Signed Partner JWT |

### Request body

```json theme={null}
{
  "email": "user@example.com"
}
```

| Field   | Type   | Required | Description                                                                |
| ------- | ------ | -------- | -------------------------------------------------------------------------- |
| `email` | string | Yes      | The individual recipient's AIR Account email. Routing key for the account. |

### Response

```json theme={null}
{
  "userId": "7f01c42c-02cf-4325-96ed-ba034700f724",
  "did": "did:air:id:test:5P44fsVUhPctDTWH2Nz26pZJFsg6CqyiAELTGeVQDB",
  "publicKey": "0x04a1...",
  "status": "existing"
}
```

| Field       | Description                                                                  |
| ----------- | ---------------------------------------------------------------------------- |
| `userId`    | Target user UUID                                                             |
| `did`       | Holder DID used as `holderDid` when storing the credential                   |
| `publicKey` | Holder public key; encrypt the VC payload to this key                        |
| `status`    | `existing` or `new` depending on whether the account was resolved or created |

<Note>
  The bundled `air-issuer-service` CSV runner currently uses AIR's legacy v2 bootstrap contract: `POST {AIR_API_ORIGIN}/v2/auth/initialize-user`, an `x-partner-id` header, and `{ "partnerJwt": "..." }` in the request body. For that compatibility contract only, the runner places the recipient email in the JWT and does not set `scope`. New custom integrations should use the v1 contract above: `x-partner-auth`, `scope: "issue"`, and `{ "email": "..." }` in the request body.
</Note>

## 2) Store encrypted VC

Store the issuer-signed, holder-encrypted VC envelope in DStorage.

```
POST /dstorage/vcs
```

### Request headers

| Header           | Required | Value              |
| ---------------- | -------- | ------------------ |
| `Content-Type`   | Yes      | `application/json` |
| `x-partner-auth` | Yes      | Signed Partner JWT |

### Request body

```json theme={null}
{
  "holderDid": "did:air:id:test:5P44fsVUhPctDTWH2Nz26pZJFsg6CqyiAELTGeVQDB",
  "schemaId": "c21s70g0i54sn0023172Cv",
  "expiresAt": "2027-07-28T08:00:00.000Z",
  "data": "<base64 ciphertext>",
  "iv": "<base64 initialization vector>",
  "authTag": "<base64 authentication tag>",
  "encryptedKey": "<base64 ephemeral public key>",
  "externalId": "urn:uuid:7f01c42c-02cf-4325-96ed-ba034700f724"
}
```

| Field          | Type            | Required | Description                                    |
| -------------- | --------------- | -------- | ---------------------------------------------- |
| `holderDid`    | string          | Yes      | Holder DID from `initialize-user`              |
| `schemaId`     | string          | Yes      | Schema the credential is built on              |
| `expiresAt`    | ISO 8601 string | Yes      | Credential expiration time                     |
| `data`         | base64 string   | Yes      | Encrypted, issuer-signed VC                    |
| `iv`           | base64 string   | Yes      | AES-GCM initialization vector                  |
| `authTag`      | base64 string   | Yes      | AES-GCM authentication tag                     |
| `encryptedKey` | base64 string   | Yes      | Ephemeral data-encryption public key           |
| `externalId`   | string          | Yes      | Issuer-controlled unique credential identifier |

The issuer service encrypts the signed credential with X25519 key agreement, HKDF-SHA-256, and AES-256-GCM. Its encryption helper returns `{ encryptedData, iv, authTag, dataEncPublicKey }`; map `encryptedData` to `data` and `dataEncPublicKey` to `encryptedKey` when calling DStorage.

### Response

```json theme={null}
{
  "storagePath": "dstorage://vc/7f01c42c/c28t30c048pe502a3713w0"
}
```

Returns `201` with the `storagePath` / storage reference. Record it as your issuance result.

## 3) Issuer-hosted endpoints

For the hosted Issuer Backend path, AIR calls your endpoints with an identity it resolved server-side. Both endpoints require:

```http theme={null}
Content-Type: application/json
x-api-key: <issuerBackendApiKey>
```

Do not authorize issuance from `holderDID` alone. Use the AIR-resolved `userId` to look up eligibility and claims in your own systems.

### `POST /available-vc`

Return credentials the holder can claim. `schemaId` and `proofType` are optional filters.

```json theme={null}
{
  "holderDID": "did:air:id:test:5P44fsVUhPctDTWH2Nz26pZJFsg6CqyiAELTGeVQDB",
  "pubKey": "0x04a1...",
  "userId": "partner-user-123",
  "schemaId": "c21s70g0i54sn0023172Cv",
  "proofType": "BJJ_SIG_2021"
}
```

| Field       | Type   | Required | Description                                                       |
| ----------- | ------ | -------- | ----------------------------------------------------------------- |
| `holderDID` | string | Yes      | Holder DID resolved by AIR                                        |
| `pubKey`    | string | Yes      | Holder public key used to encrypt each credential subject preview |
| `userId`    | string | Yes      | Partner primary identifier resolved by AIR                        |
| `schemaId`  | string | No       | Return only this schema                                           |
| `proofType` | string | No       | Return only this proof type                                       |

Response:

```json theme={null}
{
  "data": [
    {
      "holderDID": "did:air:id:test:5P44fsVUhPctDTWH2Nz26pZJFsg6CqyiAELTGeVQDB",
      "schemaId": "c21s70g0i54sn0023172Cv",
      "credentialSubject": {
        "encryptedData": "<base64 ciphertext>",
        "iv": "<base64 initialization vector>",
        "authTag": "<base64 authentication tag>",
        "dataEncPublicKey": "<base64 ephemeral public key>"
      },
      "proofType": "BJJ_SIG_2021"
    }
  ]
}
```

### `POST /issue-vc`

Issue one credential, encrypt it to the holder, upload it to DStorage, and persist the issuance record.

```json theme={null}
{
  "holderDID": "did:air:id:test:5P44fsVUhPctDTWH2Nz26pZJFsg6CqyiAELTGeVQDB",
  "pubKey": "0x04a1...",
  "userId": "partner-user-123",
  "schemaId": "c21s70g0i54sn0023172Cv",
  "proofType": "BJJ_SIG_2021"
}
```

| Field       | Type               | Required | Description                                                      |
| ----------- | ------------------ | -------- | ---------------------------------------------------------------- |
| `holderDID` | string             | Yes      | Holder DID resolved by AIR                                       |
| `pubKey`    | hexadecimal string | Yes      | Holder public key used to encrypt the complete issued credential |
| `userId`    | string             | Yes      | Partner primary identifier resolved by AIR                       |
| `schemaId`  | string             | Yes      | Schema to issue                                                  |
| `proofType` | string             | No       | Proof type; defaults to `BJJ_SIG_2021`                           |

On success, the endpoint returns an empty response body. The issuer backend uploads the encrypted credential and stores the DStorage response internally; callers should not expect a `storagePath` in this response.

## 4) Credential status and revocation

The issuer service exposes public status URLs for verifiers and admin-protected endpoints for issuer operations. Set `ISSUER_ORIGIN` to the public HTTPS origin serving these routes; the credential status URL is embedded in every issued BJJ credential.

### Public status endpoints

These endpoints do not use an API key:

| Endpoint                        | Purpose                                                                               | Response                   |
| ------------------------------- | ------------------------------------------------------------------------------------- | -------------------------- |
| `GET /credential-status/:nonce` | Return the non-revocation Merkle proof and issuer tree state for the credential nonce | `{ mtp, issuer }`          |
| `GET /revocation-status/:nonce` | Check whether a credential nonce has been revoked                                     | `{ "isRevoked": boolean }` |

Example revocation status response:

```json theme={null}
{
  "isRevoked": false
}
```

### Admin endpoints

Admin endpoints require:

```http theme={null}
x-admin-api-key: <ADMIN_API_KEY>
```

#### `GET /admin/issuance-history`

Returns paginated issuance records. Supported query parameters:

| Parameter         | Required | Description                                      |
| ----------------- | -------- | ------------------------------------------------ |
| `page`            | No       | Positive page number; defaults to `1`            |
| `limit`           | No       | Positive page size up to `100`; defaults to `25` |
| `order`           | No       | Sort expression such as `id_asc` or `id_desc`    |
| `holderDid`       | No       | Exact holder DID filter                          |
| `schemaId`        | No       | Exact schema ID filter                           |
| `revocationNonce` | No       | Exact revocation nonce filter                    |

```json theme={null}
{
  "data": [
    {
      "holderDid": "did:air:id:test:5P44fsVUhPctDTWH2Nz26pZJFsg6CqyiAELTGeVQDB",
      "schemaId": "c21s70g0i54sn0023172Cv",
      "revocationNonce": "123456789",
      "createdAt": "2026-07-28T08:00:00.000Z",
      "expiresAt": "2027-07-28T08:00:00.000Z",
      "revokedAt": null,
      "type": "bjj"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 25,
    "total": 1
  }
}
```

#### `POST /admin/revoke`

Revoke the credential identified by its nonce:

```json theme={null}
{
  "nonce": "123456789"
}
```

On success, the endpoint returns an empty response body, marks the nonce as revoked, and sets `revokedAt` on matching issuance records.

## Script example (Node.js)

A minimal script-based direct issuance flow: resolve the user, build/sign/encrypt the VC, store it in DStorage.

```js theme={null}
require("dotenv").config();
const { getPartnerJwt } = require("./lib/jwt");
// buildVc/signVc/encryptToHolder are issuer-controlled helpers backed by your signing keys
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 initializeUser(recipientEmail) {
  // recipientEmail is the routing key — resolve it per recipient, never reuse a shared value
  const token = await getPartnerJwt(); // scope: "issue", no email claim
  const res = await fetch(`${BASE_URL}/auth/initialize-user`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "x-partner-auth": token },
    body: JSON.stringify({ email: recipientEmail }),
  });
  if (!res.ok) throw new Error(`initialize-user failed: ${res.status}`);
  return res.json(); // { userId, did, publicKey, status }
}

async function storeVc(payload) {
  const token = await getPartnerJwt();
  const res = await fetch(`${BASE_URL}/dstorage/vcs`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "x-partner-auth": token },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error(`dstorage/vcs failed: ${res.status}`);
  return res.json(); // { storagePath }
}

async function issue(recipientEmail, schemaId, credentialSubject) {
  const { did, publicKey } = await initializeUser(recipientEmail);

  const expiresAt = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
  const vc = buildVc({ holderDid: did, schemaId, credentialSubject, expirationDate: expiresAt });
  const signed = signVc(vc); // sign with issuer-controlled key (BJJ_SIG_2021)
  const encrypted = encryptToHolder(signed, publicKey);

  const { storagePath } = await storeVc({
    holderDid: did,
    schemaId,
    expiresAt,
    data: encrypted.encryptedData,
    iv: encrypted.iv,
    authTag: encrypted.authTag,
    encryptedKey: encrypted.dataEncPublicKey,
    externalId: vc.id,
  });
  return storagePath;
}
```

### Retry with backoff

```js theme={null}
async function issueWithRetry(recipientEmail, schemaId, subject, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await issue(recipientEmail, schemaId, subject);
    } catch (error) {
      if (attempt === maxRetries) throw error;
      await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
    }
  }
}
```

<Warning>
  The `email` passed to `initialize-user` must be the **individual recipient's** AIR Account email — it is the routing key that determines which account the credential lands in. Resolve it per recipient; never reuse a partner, service, admin, or static email across recipients.
</Warning>

## Error reference

| HTTP status | Likely cause                              | Suggested fix                                                             |
| ----------- | ----------------------------------------- | ------------------------------------------------------------------------- |
| 400         | Missing/invalid request fields            | Verify `email`, `holderDid`, `schemaId`, and all encrypted payload fields |
| 401         | Invalid/expired JWT, JWKS mismatch        | Validate signature, `kid`, `typ: "JWT"`, token expiry                     |
| 403         | Feature not enabled or schema not allowed | Enable feature in dashboard, verify schema ownership                      |
| 404         | Unknown issuer/program/user               | Recheck IDs and recipient email in dashboard                              |
| 409         | Consent rejected / conflict               | Check user consent and duplicate handling                                 |
| 500         | Server-side failure                       | Retry with backoff and inspect logs                                       |

## Troubleshooting

* If `dstorage/vcs` succeeds, record the `storagePath`; the credential is available for the holder to present at any verifier.
* For v1 authentication errors, verify the JWT contains `partnerId` and `scope: "issue"` (not the recipient email), and that the header includes `typ: "JWT"`.
* Ensure your JWKS endpoint is public and `kid` maps to the signing key.
