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

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.

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) or a did:web that follows the did:web method (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 (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 to call the Credentials worker:

    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.

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.

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.

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

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.

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 }:

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); 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).

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.

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

LimitValue
Offer body16 KB
expires_atIn the future, at most 30 days ahead
Waiting offers per sender per person2 (requests are counted separately); 429 too_many_pending
Waiting items per sender, across all peopleSENDER_PENDING_CAP (default 2,000); 429 too_many_pending
Senders countedEvery 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 lists20 per issuer, 64 KB each
Storage guardNear 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
RatePer 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).

  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