# Issue a credential

> Issue an SD-JWT VC from your own service with IXO Credentials: publish your key, serve OpenID4VCI with offers by reference and subject binding, publish status lists and deposit offers.

Use this guide to issue IXO credentials from your own service: a person's wallet collects each credential over OpenID4VCI after you deposit an offer for their DID. Self, IXO's identity verification service, is the reference issuer (built, not live yet); every step here is one Self takes, and the devnet test issuer follows the same steps. For the roles and terms, read the [IXO Credentials overview](/guides/dev/credentials/overview) first.

<Info>
  The Credentials worker is live. In the code below, `CREDENTIALS_WORKER_URL` is the worker of your network: `https://dev.credentials.ixo.earth` (devnet), `https://test.credentials.ixo.earth` (testnet) or `https://credentials.ixo.earth` (mainnet), and `CREDENTIALS_WORKER_DID` is its `did:web` (for example `did:web:dev.credentials.ixo.earth`). `ISSUER_URL` is your own host. Build and test on devnet first: the devnet Auth Hub at `https://dev.auth.ixo.earth/credentials` accepts your offers like any other.
</Info>

The code is an illustrative example: the helper functions you write yourself (`mintSingleUseCode`, `loadIssuance`, `spendNonceOnce`, `oid4vciError`, `mint`) are named where they are used. To see a working issuer from the outside first, read the devnet test issuer's metadata in [Try it on devnet](/guides/dev/credentials/quickstart).

## Prerequisites

You need:

- an issuer DID with an Ed25519 key listed under `assertionMethod` on its DID document: an IXO entity DID (`did:ixo:entity:…`, see [entities](/guides/dev/entities)) or a `did:web` that follows the [did:web method](https://w3c-ccg.github.io/did-method-web/) (`did:web:HOST` is served at `https://HOST/.well-known/did.json`, `did:web:HOST:PATH` at `https://HOST/PATH/did.json`; a subdomain, a port written `%3A` and a path all work). IXO's own services may use either;
- an `https` host for your credential issuer identifier, for example `ISSUER_URL/identity`;
- a credential type: a `vct` string, a configuration id, and which claims are selective disclosures. The Credentials worker does not host types; publish new ones the IXO way, in the GitHub repository `ixofoundation/ns` with a protocol entity;
- the kit [`@ixo/credentials-kit`](https://www.npmjs.com/package/@ixo/credentials-kit) (pin an exact version, `0.1.1` today), an SD-JWT library to mint the credential (the kit verifies and presents but does not mint; Self uses `@sd-jwt/core`), and [`@ixo/ucan`](https://www.npmjs.com/package/@ixo/ucan) to call the Credentials worker:

  ```bash
  npm install @ixo/credentials-kit@0.1.1 @ixo/ucan @sd-jwt/core
  ```

- a database for offer references, codes, status indexes and status bits.

## What this guide does

You will serve the standard OpenID4VCI endpoints, serve each offer by reference, check that each credential is bound to a key on the person's own DID document, give each credential a revocable status, and deliver offers through the Credentials worker's inbox. Success is a credential a verifier accepts and you can revoke.

```mermaid
sequenceDiagram
  autonumber
  participant I as Issuer
  participant W as Credentials worker
  participant H as Wallet
  I->>W: PUT /status/ISSUER_DID/LIST_ID (before the first credential on the list)
  I->>W: POST /v1/offers {subject, ref, credential_offer_uri, display, expires_at, consent?}
  W-->>I: 201 {id, status: pending, consent}
  W-->>H: webhook offer.created (Auth Hub only, other wallets poll)
  H->>W: GET /v1/offers/OFFER_ID (as the person)
  W-->>H: summary + credential_offer_uri (+ consent_token)
  H->>I: GET credential_offer_uri
  I-->>H: offer with a fresh pre-authorized code (5 min, single use)
  H->>I: GET issuer metadata and OAuth server metadata
  H->>I: POST token (pre-authorized code, no client authentication)
  I-->>H: access_token
  H->>I: POST nonce
  I-->>H: c_nonce
  H->>I: POST credential (one jwt proof signed by the person's key)
  I->>I: verify proof, subject binding, mint with cnf = proof key
  I-->>H: {credentials: [{credential}], notification_id}
  H->>H: verifyIssuedCredential, then store in the Vault
  H->>I: POST notification credential_accepted
  H->>W: POST /v1/offers/OFFER_ID/status {accepted}
  I->>W: POST /v1/offers/OFFER_ID/status {retracted} (tidy-up, a no-op once closed)
```

## Step 1 — Serve metadata, token and nonce endpoints

Serve two metadata documents for your identifier `ISSUER_URL/identity`:

- issuer metadata at `ISSUER_URL/.well-known/openid-credential-issuer/identity`, built with the kit's `buildIssuerMetadata`: one `dc+sd-jwt` configuration per type, `credential_signing_alg_values_supported` and `proof_signing_alg_values_supported` `["EdDSA", "Ed25519"]`, `cryptographic_binding_methods_supported` `["did:ixo"]`, no `scope`, no deferred endpoint;
- OAuth server metadata at `ISSUER_URL/.well-known/oauth-authorization-server/identity`, built with `buildAuthorizationServerMetadata`: `token_endpoint`, the pre-authorized code grant, `pre-authorized_grant_anonymous_access_supported: true` and `token_endpoint_auth_methods_supported: ["none"]`. Wallets find your token endpoint here.

Then the two endpoints:

- **Token endpoint:** accept `grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code` without client authentication. Store codes only as hashes and spend each once. Refuse a `tx_code` (the profile has no transaction codes in v1). Self also honours an `authorization_details` entry of type `openid_credential` that names its configuration, and echoes it with `credential_identifiers`.
- **Nonce endpoint:** return a fresh, unpredictable `c_nonce` with `Cache-Control: no-store`. Self's nonces are stateless and signed, valid for 5 minutes, and accepted for one proof only.

## Step 2 — Serve the offer by reference

Give each issuance a random offer reference (Self: 32 random bytes) and store only its hash. Serve `GET ISSUER_URL/identity/oid4vci/offers/OFFER_REF` (any path works; this is Self's): each GET mints a **fresh** pre-authorized code that lives at most 5 minutes and can be used once, and answers the standard offer object.

```ts
import { buildCredentialOffer } from '@ixo/credentials-kit/oid4vci';

const offer = buildCredentialOffer({
  credentialIssuer: 'ISSUER_URL/identity',
  configurationIds: ['IdentityLevel1'],
  preAuthorizedCode: await mintSingleUseCode(issuance, { ttlSeconds: 300 }),
});
// Respond with Content-Type: application/json and Cache-Control: no-store
```

Once the issuance is over (collected, cancelled or expired), answer `404`. The `credential_offer_uri` is what you deposit at the Credentials worker in step 5.

## Step 3 — Bind every credential to the subject's key

In the credential endpoint, verify the wallet's single proof with the kit. `verifyProofJwt` checks `typ`, the signature by the Ed25519 key the `kid` names under `authentication` on its DID document, `aud`, `iat` and then your nonce check. Subject binding is your part: the proof's DID must be the subject from your own records, never one taken from the proof.

```ts
import { verifyProofJwt } from '@ixo/credentials-kit/oid4vci';

const issuance = await loadIssuance(accessToken); // your records
const proof = await verifyProofJwt(body.proofs.jwt[0], {
  audience: 'ISSUER_URL/identity',
  nonce: n => spendNonceOnce(n), // called only after the signature verified
  resolver,
});
if (proof.did !== issuance.subjectDid) return oid4vciError('invalid_proof');

const credential = await mint({
  sub: issuance.subjectDid,
  cnf: { jwk: proof.jwk }, // exactly the proof key
  status: { status_list: { uri: statusListUri, idx } }, // step 4
});
return Response.json({ credentials: [{ credential }], notification_id });
```

Mint with header `typ: "dc+sd-jwt"`, `alg: "EdDSA"` and a `kid` naming your `assertionMethod` key; `iss` your DID, `sub`, `iat`, `nbf`, `exp`, `vct`, `cnf.jwk` and `status` in the signed payload. If the credential carries a birth date, add `age_over_18`, `age_over_21` (booleans) and `age_birth_year` (number), each as its own disclosure, computed when you decide.

Self has no deferred endpoint: an offer only exists once the verification is approved, so the credential is always ready. The kit wallet can poll a deferred endpoint, but the Auth Hub leaves a deferred offer pending and the person tries again.

## Step 4 — Give each credential a status

```ts
import {
  newStatusBits,
  randomFreeIndex,
  setStatusBit,
  createStatusListToken,
} from '@ixo/credentials-kit/status';

const idx = randomFreeIndex(bits, LIST_SIZE); // record it against the credential
const token = await createStatusListToken({
  bits,
  uri: `CREDENTIALS_WORKER_URL/status/${ISSUER_DID}/${listId}`,
  iss: ISSUER_DID,
  signer,
  ttl: 300,
  iat: now,
  exp: now + 86400,
});
```

Publish the token with `PUT /status/ISSUER_DID/LIST_ID` before you release the first credential on a list, after every revocation (`setStatusBit`), and from a scheduled job so it never expires. Each publish needs an `iat` strictly newer than the stored token's; on `409 stale`, sign again with a fresh `iat`. Details and limits in [Status lists and the catalog](/guides/dev/credentials/status-lists-and-catalog).

## Step 5 — Deposit the offer

When a credential is ready, deposit the offer for the person's DID. Every call to the Credentials worker carries a fresh UCAN invocation, signed with your issuer key (a key on your own DID document), on resource `ixo:credentials`, addressed to the worker's `did:web`, valid at most 300 seconds, used once, and bound to this one request by the fact `{ method, path }`:

```ts
import { createInvocation, serializeInvocation } from '@ixo/ucan';

async function workerBearer(method: string, path: string) {
  const invocation = await createInvocation({
    issuer: ucanSigner, // your issuer key, bound to ISSUER_DID with .withDID(ISSUER_DID)
    audience: CREDENTIALS_WORKER_DID, // from CREDENTIALS_WORKER_URL/.well-known/did.json
    capability: { can: 'credentials/issue', with: 'ixo:credentials' },
    expiration: Math.floor(Date.now() / 1000) + 120,
    facts: [{ method, path }, { nonce: crypto.randomUUID() }], // path without the query
  });
  return serializeInvocation(invocation);
}

const res = await fetch('CREDENTIALS_WORKER_URL/v1/offers', {
  method: 'POST',
  headers: {
    authorization: `Bearer ${await workerBearer('POST', '/v1/offers')}`,
    'content-type': 'application/json',
  },
  body: JSON.stringify({
    subject: 'did:ixo:ixo1qqqsyqcyq5rqwzqfpg9scrgwpugpzysn4uq0hv',
    ref: 'vid_01JC9X', // your id; deposits are idempotent on (issuer, ref)
    credential_offer_uri: 'ISSUER_URL/identity/oid4vci/offers/OFFER_REF',
    display: { name: 'Identity Level 1', vct: 'VCT' },
    expires_at: 1793966400, // unix seconds, at most 30 days ahead
    // consent: the person's offer consent, when they gave one
  }),
  redirect: 'manual',
  signal: AbortSignal.timeout(10_000),
});
// 201 { id, status: 'pending', consent: 'valid' | 'none', display_dropped: [] }
// The same body again answers 200.
```

For an entity issuer the invocation must be signed with a key on the entity's own DID document. A controller or DAO member acting through `ixo:credentials/ENTITY_DID` can write the entity's catalog entry, but not deposit offers.

Rules the worker applies:

- `subject` must be a `did:ixo:ixo1…` person that exists on chain, else `400 unknown_subject`.
- Send exactly one of `credential_offer_uri` (preferred; the worker never fetches it) or `credential_offer` (the offer by value). URLs must be `https` on a public host.
- `display` is `{ name, vct, description? }` and nothing else. Your name, logo and card look come only from your catalog entry (step 6). Any other member, a card look included, is not stored and is listed in the answer's `display_dropped`.
- The same `ref` with a different body is `409 ref_in_use`.
- `consent`, when present, must be a valid UCAN delegation from the subject to you, ability `credentials/offer` on `ixo:credentials`, `nb.vct` containing `display.vct`, expiring within 366 days and not revoked; otherwise `400`.
- If the person blocked you, the answer is `403 blocked` and nothing is stored.
- Waiting limits apply to every sender alike (see [Limits](#limits)); over a limit the answer is `429 too_many_pending`.

A failed deposit must not break your own flow; retry later with the same body, honouring `Retry-After`. Retract an offer the person no longer needs with `POST /v1/offers/OFFER_ID/status` and `{ "status": "retracted" }`. To let the Auth Hub save the credential without asking, attach the person's offer consent as `consent` (see [Hold and share credentials](/guides/dev/credentials/hold-and-share#automatic-saving)).

<Note>
  Self (not live yet) deposits when a verification is approved, from its Durable Object alarm, with `expires_at` set to the verification's expiry. An uncollected Self offer can be collected for about 30 minutes after approval; after that the minted credential is wiped and the offer retracted.
</Note>

## Step 6 — Handle notifications and publish your catalog entry

- On `credential_accepted` at your notification endpoint, record the credential as collected and retract the inbox offer. On `credential_failure`, log it. Answer `204`; repeats are no-ops.
- Write your issuer entry with `PUT /v1/catalog/issuers/ISSUER_DID` on each network you issue on, and again from a daily job. It is the only place wallets take your name, logo (`logo_uri`), mark color (`color`) and each type's card look (`types[].display`) from; without it, wallets show a neutral card. Listing is self-service: there is no application or approval, and the entry is your own claim about yourself; it does not make you trusted. See [Get listed in the catalog](/guides/dev/credentials/status-lists-and-catalog#get-listed-in-the-catalog).

Self's cancel rule is a useful pattern: once a wallet has collected the credential, a cancel is refused; a credential minted but not collected is revoked by the cancel. If you let a collected credential be cancelled, revoke it at the same time. Self will do the same when it adds that kind of cancel.

## Limits

| Limit | Value |
| --- | --- |
| Offer body | 16 KB |
| `expires_at` | In the future, at most 30 days ahead |
| Waiting offers per sender per person | 2 (requests are counted separately); `429 too_many_pending` |
| Waiting items per sender, across all people | `SENDER_PENDING_CAP` (default 2,000); `429 too_many_pending` |
| Senders counted | Every sender under the same rule; every DID is its own sender, exactly as written (a `did:web` on a subdomain or with a path is its own sender, with its own limits, block and catalog entry) |
| Status lists | 20 per issuer, 64 KB each |
| Storage guard | Near the worker's storage limit, a new status list or a new or larger catalog entry is refused with `507 storage_full`; updates of what you already publish still work |
| Rate | Per principal on authenticated routes; `429 rate_limited` with `Retry-After` |

## Verify the result

Run these on devnet, with your own devnet account as the test person (see [Try it on devnet](/guides/dev/credentials/quickstart#prerequisites)).

1. As the test person, list their offers: `GET /v1/offers?status=pending` returns yours, without any code.
2. Accept it in the devnet Auth Hub at `https://dev.auth.ixo.earth/credentials`, or redeem it with the kit's `redeemOffer`. The offer becomes `accepted` and your notification endpoint receives `credential_accepted`.
3. Verify a presentation of the credential with the kit's `verifyPresentation`, accepting your DID as issuer. Expect `ok: true`.
4. Flip its bit, publish, wait `ttl` seconds, verify again. Expect `revoked`.
5. Redeem with a key that is not on the test person's DID document. Expect `invalid_proof`.

## Troubleshooting

### Verifiers report `issuer_key_unpublished`

The `kid` in your credential is not under `assertionMethod` on your DID document, or you removed the key. Keep a key published for as long as credentials signed with it should verify.

### The worker answers `401`

The invocation is missing the `{ method, path }` fact or names another request, was used before, expired, is addressed to the wrong `did:web`, or is signed by a DID method other than `did:ixo` or `did:web`.

### The worker answers `403` on a deposit

With `blocked`: the person blocked you. Do not retry; the block stays until the person removes it.

With `forbidden`: you invoked on `ixo:credentials/ENTITY_DID` as a controller. Deposits need the entity's own key on `ixo:credentials`.

### The worker answers `429 too_many_pending`

You already have 2 offers waiting for this person, or `SENDER_PENDING_CAP` waiting in all. Retract offers the person no longer needs, or wait until they deal with them.

### Your look or logo is not shown

Offers carry no look. Put `logo_uri`, `color` and `types[].display` in your catalog entry, and check the `display_dropped` list in the `PUT` answer for members that were refused.

### The registry refuses your status list

The token's `sub` must equal the exact public URL, `iss` your DID, `exp` after `iat`, in the future and at most 400 days ahead, `iat` at most 60 seconds ahead and strictly newer than the stored token (`409 stale`). An issuer keeps at most 20 lists (`409 too_many_lists`).

### Wallets get `invalid_proof`

The wallet signed with a key that is not under `authentication` on the subject's DID document, or with another DID's key. That is the subject-binding check working.

## Next steps

- [IXO Credentials overview](/guides/dev/credentials/overview)
- [Status lists and the catalog](/guides/dev/credentials/status-lists-and-catalog)
- [Credentials API reference](/guides/dev/credentials/api-reference)
- [Request proof](/guides/dev/credentials/request-proof)
