# 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](https://test-verifier.dev.credentials.ixo.earth/docs)).

<Info>
  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`).
</Info>

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`](https://www.npmjs.com/package/@ixo/credentials-kit) (pin an exact version, `0.1.1` today) and [`@ixo/ucan`](https://www.npmjs.com/package/@ixo/ucan):

  ```bash
  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.

```mermaid
sequenceDiagram
  autonumber
  participant Ve as Your service
  participant W as Credentials worker
  participant H as Wallet
  participant P as Person
  Ve->>Ve: fresh nonce, state, P-256 key pair, signed request object, store the check
  Ve->>W: POST /v1/requests {subject, ref, request, purpose, expires_at?}
  W->>W: verify request object (signature, client_id = caller, exp, nonce, DCQL subset, https response_uri, direct_post.jwt)
  W-->>Ve: 201 {id, request_uri, openid4vp_url, wallet_url?, status}
  W-->>H: webhook request.created (Auth Hub only, other wallets poll or open openid4vp_url)
  Ve-->>P: link to wallet_url (Hub) or openid4vp_url (any wallet)
  H->>W: GET /v1/requests/REQUEST_ID (as the person)
  H->>H: verifyAuthorizationRequest, match credentials
  P->>H: Share (or a standing consent covers it)
  H->>Ve: POST response_uri: response=JWE of {vp_token, state}
  Ve->>Ve: decrypt, compare state, verifyAuthorizationResponse (check untouched)
  alt holderAuthenticated
    Ve->>Ve: close the check once, as verified or failed
    Ve-->>H: 200 {}
  else not holder-authenticated
    Ve-->>H: 400, the check stays open
  end
  H->>W: POST /v1/requests/REQUEST_ID/status {answered}
```

## Step 1 — Build the request

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

```ts
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.
```

```json
{
  "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`.

<Warning>
  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.
</Warning>

```ts
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](/guides/dev/credentials/status-lists-and-catalog#get-listed-in-the-catalog).

## One claim per person with `person_ref`

<Note>
  **Not live yet.** `person_ref` comes with Self's `IdentityLevel1` credential. Self is built but not deployed, so no credential carries it today.
</Note>

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:

```ts
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](/guides/dev/credentials/quickstart)).

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

- [Credentials API reference](/guides/dev/credentials/api-reference)
- [Status lists and the catalog](/guides/dev/credentials/status-lists-and-catalog)
- [Hold and share credentials](/guides/dev/credentials/hold-and-share)
- [IXO Credentials overview](/guides/dev/credentials/overview)
