Request proof

Ask a person for proof from an IXO credential and verify the answer: build a signed OpenID4VP request, deposit it for their DID, and check the encrypted response with key binding, the expected subject and status before closing the check.

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 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:web served from your host, or an IXO entity DID, with an Ed25519 signing key on its DID document;

  • an https endpoint to receive responses, for example VERIFIER_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.1 today) 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.

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

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

LimitValue
Request body18 KB (request object at most 16 KB)
purpose140 characters
expires_atDefault 15 minutes, at most 24 hours, never after the request object's exp
Waiting requests per sender per person2 (offers 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)
RatePer 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).

  1. Start a check for the test person, and approve it at wallet_url.
  2. Your check shows verified with age_over_18: true.
  3. Post the same response body again. Expect a refusal: the check is closed and the nonce spent.
  4. 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.
  5. Ask the issuer to revoke the credential, wait for the status list ttl, and run a new check. Expect failed with revoked.

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.

Next steps