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 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.
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
assertionMethodon its DID document: an IXO entity DID (did:ixo:entity:…, see entities) or adid:webthat follows the did:web method (did:web:HOSTis served athttps://HOST/.well-known/did.json,did:web:HOST:PATHathttps://HOST/PATH/did.json; a subdomain, a port written%3Aand a path all work). IXO's own services may use either; -
an
httpshost for your credential issuer identifier, for exampleISSUER_URL/identity; -
a credential type: a
vctstring, 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 repositoryixofoundation/nswith a protocol entity; -
the kit
@ixo/credentials-kit(pin an exact version,0.1.1today), an SD-JWT library to mint the credential (the kit verifies and presents but does not mint; Self uses@sd-jwt/core), and@ixo/ucanto 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'sbuildIssuerMetadata: onedc+sd-jwtconfiguration per type,credential_signing_alg_values_supportedandproof_signing_alg_values_supported["EdDSA", "Ed25519"],cryptographic_binding_methods_supported["did:ixo"], noscope, no deferred endpoint; - OAuth server metadata at
ISSUER_URL/.well-known/oauth-authorization-server/identity, built withbuildAuthorizationServerMetadata:token_endpoint, the pre-authorized code grant,pre-authorized_grant_anonymous_access_supported: trueandtoken_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_codewithout client authentication. Store codes only as hashes and spend each once. Refuse atx_code(the profile has no transaction codes in v1). Self also honours anauthorization_detailsentry of typeopenid_credentialthat names its configuration, and echoes it withcredential_identifiers. - Nonce endpoint: return a fresh, unpredictable
c_noncewithCache-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-storeOnce 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:
subjectmust be adid:ixo:ixo1…person that exists on chain, else400 unknown_subject.- Send exactly one of
credential_offer_uri(preferred; the worker never fetches it) orcredential_offer(the offer by value). URLs must behttpson a public host. displayis{ 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'sdisplay_dropped.- The same
refwith a different body is409 ref_in_use. consent, when present, must be a valid UCAN delegation from the subject to you, abilitycredentials/offeronixo:credentials,nb.vctcontainingdisplay.vct, expiring within 366 days and not revoked; otherwise400.- If the person blocked you, the answer is
403 blockedand 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).
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.
Step 6 — Handle notifications and publish your catalog entry
- On
credential_acceptedat your notification endpoint, record the credential as collected and retract the inbox offer. Oncredential_failure, log it. Answer204; repeats are no-ops. - Write your issuer entry with
PUT /v1/catalog/issuers/ISSUER_DIDon 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
| 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).
- As the test person, list their offers:
GET /v1/offers?status=pendingreturns yours, without any code. - Accept it in the devnet Auth Hub at
https://dev.auth.ixo.earth/credentials, or redeem it with the kit'sredeemOffer. The offer becomesacceptedand your notification endpoint receivescredential_accepted. - Verify a presentation of the credential with the kit's
verifyPresentation, accepting your DID as issuer. Expectok: true. - Flip its bit, publish, wait
ttlseconds, verify again. Expectrevoked. - 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.