Use this guide to ask a person to prove something from an IXO credential, for example that they are over 18, and to verify the answer in your service. You deposit a signed OpenID4VP request for the person's DID, send them to their wallet, and receive the presentation at your own endpoint. The reference integration is IXO's example verifier, which runs on devnet as the test verifier (https://test-verifier.dev.credentials.ixo.earth, API reference at /docs).
The Credentials worker and the Auth Hub wallet are live. In the code below, CREDENTIALS_WORKER_URL and HUB_URL are those of your network:
| Network | CREDENTIALS_WORKER_URL | HUB_URL |
|---|---|---|
| Devnet | https://dev.credentials.ixo.earth | https://dev.auth.ixo.earth |
| Testnet | https://test.credentials.ixo.earth | https://test.auth.ixo.earth |
| Mainnet | https://credentials.ixo.earth | https://auth.ixo.earth |
CREDENTIALS_WORKER_DID is the worker's did:web (for example did:web:dev.credentials.ixo.earth). VERIFIER_URL is your own host. Build and test on devnet first, accepting the devnet test issuer (did:web:test-issuer.dev.credentials.ixo.earth, type https://test-issuer.dev.credentials.ixo.earth/types/TestCredential).
The code is an illustrative example. The working version is the example verifier's src/routes/checks.ts and src/routes/response.ts in the ixoworld/ixo-credentials repository, which is private for now.
Prerequisites
You need:
-
a DID for your service: a
did:webserved from your host, or an IXO entity DID, with an Ed25519 signing key on its DID document; -
an
httpsendpoint to receive responses, for exampleVERIFIER_URL/response/CHECK_ID; -
your own list of accepted issuer DIDs for each credential type (there is no central trust list);
-
the kit
@ixo/credentials-kit(pin an exact version,0.1.1today) and@ixo/ucan:npm install @ixo/credentials-kit@0.1.1 @ixo/ucan -
storage for each open request: nonce, state, response private key, expiry.
Ask for as little as possible. To check age, ask for age_over_18, not the birth date. To check that someone passed identity verification at an issuer you trust, ask for no claims at all.
What this guide does
You will build a signed request with a fresh nonce and a response encryption key, deposit it at the Credentials worker, show the person a link to approve, and verify the encrypted response: issuer key, key binding to your request and to the person you asked, disclosures and revocation status. Success is a verified age_over_18: true for a test person.
Step 1 — Build the request
import {
generateResponseKeyPair,
createAuthorizationRequest,
} from '@ixo/credentials-kit/oid4vp';
const responseKeys = await generateResponseKeyPair(); // P-256
const requestJwt = await createAuthorizationRequest({
signer, // your Ed25519 key, kid on your DID document
clientDid: 'did:web:VERIFIER_HOST',
responseUri: 'VERIFIER_URL/response/CHECK_ID',
nonce: check.nonce, // fresh and random, at least 16 URL-safe characters
state: check.state, // fresh too; the response must carry it back
dcql: {
credentials: [
{
id: 'identity',
format: 'dc+sd-jwt',
meta: { vct_values: ['VCT'] },
claims: [
{ path: ['iss'], values: ['ISSUER_DID'] }, // accepted issuers
{ path: ['age_over_18'] },
],
},
],
},
responseEncryptionJwk: responseKeys.publicJwk,
exp: nowSeconds + 15 * 60, // required for a deposit at the Credentials worker
});
// Store the check BEFORE the request is visible anywhere: subject, the request JWT and the
// private key, until the check closes.The request object has header typ: "oauth-authz-req+jwt", a kid on your DID document, and client_id = decentralized_identifier:did:web:VERIFIER_HOST, response_type: vp_token, response_mode: direct_post.jwt (plain direct_post only in local development), nonce, state, exp and a DCQL query.
Only a subset of DCQL is accepted. Each claim path names one top-level claim; the format is dc+sd-jwt; credential_sets, claim_sets, trusted_authorities, multi-segment paths and the registered claims nbf, exp, vct, cnf and status are refused with unsupported_request. Unknown members are ignored, and multiple: true is answered with one presentation. The iss entry tells the wallet which issuers you accept; it never discloses anything. To check possession only, keep just the iss entry.
Step 2 — Deposit it and send the person to their wallet
Call POST /v1/requests as your DID, with a fresh UCAN invocation for credentials/verify on ixo:credentials, bound to this request by the fact { method, path }:
const invocation = await createInvocation({
issuer: ucanSigner, // your key, bound to your DID with .withDID(VERIFIER_DID)
audience: CREDENTIALS_WORKER_DID,
capability: { can: 'credentials/verify', with: 'ixo:credentials' },
expiration: Math.floor(Date.now() / 1000) + 120,
facts: [{ method: 'POST', path: '/v1/requests' }, { nonce: crypto.randomUUID() }],
});
const res = await fetch('CREDENTIALS_WORKER_URL/v1/requests', {
method: 'POST',
headers: {
authorization: `Bearer ${await serializeInvocation(invocation)}`,
'content-type': 'application/json',
},
body: JSON.stringify({
subject: 'did:ixo:ixo1qqqsyqcyq5rqwzqfpg9scrgwpugpzysn4uq0hv',
ref: check.id, // same ref + same body answers the same request
request: requestJwt,
purpose: 'Show you are over 18 to claim your reward',
expires_at: nowSeconds + 15 * 60, // unix seconds; never after the request's exp
}),
redirect: 'manual',
signal: AbortSignal.timeout(10_000),
});
// If the deposit fails, delete the check: nobody can answer it.{
"id": "REQUEST_ID",
"request_uri": "CREDENTIALS_WORKER_URL/v1/requests/REQUEST_ID/object",
"openid4vp_url": "openid4vp://?client_id=decentralized_identifier%3Adid%3Aweb%3AVERIFIER_HOST&request_uri=…",
"wallet_url": "HUB_URL/credentials/requests/REQUEST_ID",
"status": "pending"
}Send the person to wallet_url (the Auth Hub page; present only when the worker is configured with the Hub's URL), or show openid4vp_url as a link or QR code for any other wallet. You can build the same link yourself with the kit's buildWalletLink({ clientId, requestUri }). In the Hub the person sees your name and logo (from your catalog entry, step 4), your purpose line, and the details that will leave with their values, and approves there. If they earlier chose "Always for NAME", the Hub answers on its own while that consent lasts.
purpose is your own words for why you ask. It is the sentence the person reads, so write it for them. It is at most 140 characters after cleaning.
Requests expire after 15 minutes unless you set expires_at (at most 24 hours). Withdraw a request with POST /v1/requests/REQUEST_ID/status and { "status": "cancelled" }.
The worker refuses a deposit with 403 blocked when the person blocked you, and with 429 too_many_pending when you already have 2 requests waiting for this person or SENDER_PENDING_CAP waiting in all. Nothing is stored in either case; delete the check.
Step 3 — Verify the response, then touch the check
The wallet posts a form with one parameter, response, a JSON Web Encryption (JWE) to your key, to your response_uri. The kit decrypts A128GCM and A256GCM.
The request object is public: anyone holding its request_uri has your encryption key, the state and the nonce, and can build a response that decrypts and carries the right state. Verify first. Only an answer with holderAuthenticated: true closes a check.
import {
decodeOwnAuthorizationRequest,
parseAuthorizationResponse,
verifyAuthorizationResponse,
} from '@ixo/credentials-kit/oid4vp';
// 1. Refuse a closed or expired check first.
const request = decodeOwnAuthorizationRequest(check.requestJwt);
// 2. Decrypt and compare state. The check is NOT touched.
const parsed = await parseAuthorizationResponse(formBody, {
decryptionKey: check.responsePrivateJwk,
request, // makes the key mandatory for direct_post.jwt
});
if (parsed.state !== request.state) return badRequest();
// 3. Verify. Audience (your full client_id), nonce and key binding come from the request.
const out = await verifyAuthorizationResponse({
vpToken: parsed.vpToken,
request,
state: parsed.state,
policyFor: () => ({
issuers: ['ISSUER_DID'],
vcts: ['VCT'],
expectedSubject: check.subject, // without it, holderAuthenticated is always false
}),
deps: { resolver, fetch, statusCache },
});
// 4. Not proven to come from the person you asked: 400, the check stays open.
if (!out.holderAuthenticated) return badRequest();
// 5. Close the check ONCE (UPDATE … WHERE status = 'pending'): verified when out.ok,
// else failed with the code (revoked, expired, missing_claim, …). Answer 200 {}.out is { ok, results, holderAuthenticated, keyBindingValid }. results[queryId] is { ok: true, issuer, subject, vct, claims, disclosed, expiresAt, credentialSha256, holderKey } or { ok: false, code, holderAuthenticated, keyBindingValid }, where code is a kit refusal (untrusted_issuer, revoked, expired, bad_key_binding, …) or missing_presentation, unexpected_presentation, missing_claim, value_mismatch. keyBindingValid only says that the holder of the presented credential signed for this nonce, whoever that is: never close a check on it.
A VerificationUnavailable or ResolverUnavailable error means a dependency was down (IXO Blocksync, a did:web host, the status list). Answer 503, leave the check open, never accept. A wallet's unencrypted error=access_denied cannot be authenticated: acknowledge it with 200 {} and leave the check to expire.
Treat age_over_18: false as "not shown to be over 18", not as "under 18". These values are fixed when the credential is issued.
What you receive
Besides the claims you asked for, every presentation shows you, in the clear: the person's IXO account ID (sub), the holder key (cnf.jwk), the status reference (list URL and index), the issuer, the dates, and every claim the issuer did not make selectively disclosable. Store only the outcome and the booleans you need; never store the credential or log claims.
Step 4 — Publish your verifier entry
Write PUT /v1/catalog/verifiers/YOUR_DID with credentials/verify (and a request-bound invocation, as in step 2) on each network you ask on: your name, optionally logo_uri and color, and per purpose the type, the claims you ask for, and the issuers you accept, the same as in your requests. Wallets take your name and logo from this entry and nowhere else; without one, the Hub shows a neutral mark and calls you "a service". When a request asks for a claim, type or issuer your entry does not list, the Hub adds the note asks_more_than_published to its review of the request. Listing is self-service, and an entry is your own claim about yourself, not a mark of trust. See Get listed in the catalog.
One claim per person with person_ref
Not live yet. person_ref comes with Self's IdentityLevel1 credential. Self is built but not deployed, so no credential carries it today.
Self's IdentityLevel1 credential will carry person_ref, a selectively disclosable claim: 64 lower-case hex characters, derived from the identity document the person verified. It is the same for every account verified with the same document, and it cannot be turned back into the document number. Use it to allow one claim per person, for example one reward per person:
dcql: {
credentials: [
{
id: 'identity',
format: 'dc+sd-jwt',
meta: { vct_values: ['https://credentials.ixo.world/types/IdentityLevel1'] },
claims: [
{ path: ['iss'], values: ['SELF_ISSUER_DID'] },
{ path: ['person_ref'] },
],
},
],
},Store the value with what you grant, and refuse a second claim with a person_ref you already stored. Treat the stored value as personal data. Know its limits:
- It identifies a document holder, not a person: someone with two documents (a passport and an ID card, or a renewed passport with a new number) has two values.
- It is the same at every service it is shared with, so services that compare stored values can link the person. The person sees "Person reference" on the sharing screen and decides whether to share it.
- A credential issued without a document number has no
person_ref. A verifier that requires it cannot accept that person's credential.
Limits
| Limit | Value |
|---|---|
| Request body | 18 KB (request object at most 16 KB) |
purpose | 140 characters |
expires_at | Default 15 minutes, at most 24 hours, never after the request object's exp |
| Waiting requests per sender per person | 2 (offers 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) |
| Rate | Per principal; 429 rate_limited with Retry-After |
Verify the result
Run these on devnet with your own devnet account, holding a Test Credential from the devnet test issuer (see Try it on devnet).
- Start a check for the test person, and approve it at
wallet_url. - Your check shows
verifiedwithage_over_18: true. - Post the same response body again. Expect a refusal: the check is closed and the nonce spent.
- Post a response built from the public request object with another person's credential. Expect
400; the check stays open and the real answer still verifies. - Ask the issuer to revoke the credential, wait for the status list
ttl, and run a new check. Expectfailedwithrevoked.
Troubleshooting
The worker answers 400 to the deposit
The client_id DID is not the DID you signed the UCAN invocation as, the request's kid is not on your DID document, exp or the nonce is missing, the DCQL is outside the subset, response_uri is not https on a public host, the response mode is not direct_post.jwt, or subject is not a person who exists (unknown_subject).
The worker answers 401
The invocation lacks the { method, path } fact for this request, was used before, expired, or is addressed to the wrong did:web.
The worker answers 403 blocked
The person blocked you. Do not retry; the block stays until the person removes it.
The worker answers 429 too_many_pending
You already have 2 requests waiting for this person, or SENDER_PENDING_CAP waiting in all. Cancel requests you no longer need, or wait until they are answered or expire.
Verification fails with bad_key_binding
The answer was signed for another nonce or audience. The kit takes both from your request; check you passed the request you stored for this check.
Verification fails with untrusted_issuer
The credential's issuer is not in your issuers list. Check that the same list is in your request's iss filter.
Every answer stays open
holderAuthenticated is false without expectedSubject. Set it to the person the check is for.