# Credentials API reference

> Authentication, endpoints, limits, error codes and webhook of the Credentials worker, and a summary of the Auth Hub's credentials routes, for IXO Credentials.

## Overview

This page lists the HTTP interfaces of IXO Credentials: the **Credentials worker** (offer inbox, presentation broker, block list, status-list registry, catalog) and the Auth Hub's `/api/credentials` routes. It does not cover the OpenID4VCI endpoints each issuer hosts or the response endpoint each verifier hosts; those follow the standards (see [Issue a credential](/guides/dev/credentials/issue-a-credential) and [Request proof](/guides/dev/credentials/request-proof)).

The worker describes itself on every host: `/openapi.json` (the full contract, OpenAPI 3), `/docs` (an interactive reference) and `/llms.txt` (a short summary for agents). Where this page and `/openapi.json` differ, `/openapi.json` is right.

| Network | Base URL | Worker DID (UCAN audience) | Live reference | Auth Hub |
| --- | --- | --- | --- | --- |
| Devnet | `https://dev.credentials.ixo.earth` | `did:web:dev.credentials.ixo.earth` | [/docs](https://dev.credentials.ixo.earth/docs), [/openapi.json](https://dev.credentials.ixo.earth/openapi.json), [/llms.txt](https://dev.credentials.ixo.earth/llms.txt) | `https://dev.auth.ixo.earth` |
| Testnet | `https://test.credentials.ixo.earth` | `did:web:test.credentials.ixo.earth` | [/docs](https://test.credentials.ixo.earth/docs), [/openapi.json](https://test.credentials.ixo.earth/openapi.json), [/llms.txt](https://test.credentials.ixo.earth/llms.txt) | `https://test.auth.ixo.earth` |
| Mainnet | `https://credentials.ixo.earth` | `did:web:credentials.ixo.earth` | [/docs](https://credentials.ixo.earth/docs), [/openapi.json](https://credentials.ixo.earth/openapi.json), [/llms.txt](https://credentials.ixo.earth/llms.txt) | `https://auth.ixo.earth` |

On the rest of this page, `CREDENTIALS_WORKER_URL` is one of these base URLs and `HUB_URL` the Auth Hub of the same network. The client library is [`@ixo/credentials-kit`](https://www.npmjs.com/package/@ixo/credentials-kit) (`0.1.1`), with [`@ixo/ucan`](https://www.npmjs.com/package/@ixo/ucan) for the invocations.

## Authentication

Every write, every issuer, verifier and holder read, and optionally a catalog read, carries one header:

```http
Authorization: Bearer BASE64_CAR_UCAN_INVOCATION
```

The UCAN invocation must:

- be addressed to the worker's `did:web` (served at `CREDENTIALS_WORKER_URL/.well-known/did.json`; derived from the worker's configured host, never the request's `Host` header);
- live at most 300 seconds (60 seconds of clock skew are tolerated) and be used once;
- carry the fact `{ "method": "<upper-case HTTP method>", "path": "<full path, no query>" }` naming the one request it is for, for example `{ "method": "POST", "path": "/v1/requests" }`;
- be signed, at every link of its proof chain, by a `did:ixo` or `did:web` (any other method, `did:key` included, is refused);
- stay within the token caps: at most 24 KB, 12 blocks, a delegation chain at most 4 deep, 4 proofs and 8 capabilities per UCAN, no proof used twice;
- if delegated, only attenuate: the ability is granted by its parent and the resource is identical.

With a UCAN store configured, every delegation in the chain is checked against its revocation registry (revoked: `403`; registry unreachable: `503`).

| Ability | For |
| --- | --- |
| `credentials/issue` | Issuers: offers, status lists, issuer catalog entry |
| `credentials/verify` | Verifiers: requests, verifier catalog entry |
| `credentials/hold` | Holders: their own offers, requests and block list; optional token on catalog reads |
| `*` | All of the above |

| Resource | Caller acts as | Allowed for |
| --- | --- | --- |
| `ixo:credentials` | The root of the proof chain: a person, a service, or an entity signing with a key on its own DID document | Every route |
| `ixo:credentials/ENTITY_DID` | That entity, when the root is a controller of it or a member of a controlling DAO group with voting weight above zero | Catalog writes only; `403` anywhere else |

Whoever controls an entity's DID document can add a key to it and act as the entity on every route.

## Base URL

```text
https://dev.credentials.ixo.earth
https://test.credentials.ixo.earth
https://credentials.ixo.earth
```

```bash
curl -s https://dev.credentials.ixo.earth/
```

```json
{"name":"IXO Credentials","version":"0.1.0","docs":"/docs","openapi":"/openapi.json","llms":"/llms.txt","health":"/health","did":"/.well-known/did.json"}
```

Put DIDs in paths exactly as written (for example `did:web:localhost%3A34672` stays as is): path DIDs are read raw, never percent-decoded. A `did:web` follows the [did:web method](https://w3c-ccg.github.io/did-method-web/) (any subdomain, a port written `%3A`, any path) and must be written in one canonical form: a lower-case host, no trailing dot, no percent-encoding in the host other than the `%3A` before a port (never `%3A443`), a non-ASCII host name in punycode, no IP address; in a path segment, upper-case hex only, no percent-encoded letter, digit, `-`, `.`, `_` or `~`, and no `.well-known` segment. A `did:ixo` is a person (`did:ixo:ixo1…`, lower case) or an entity (`did:ixo:entity:` and 32 lower-case hex digits). Any other spelling is refused with `400` in a path or body and `401` as a signer. Every timestamp in the API is unix seconds.

## Endpoints

| Method | Path | Auth | What |
| --- | --- | --- | --- |
| GET | `/`, `/health`, `/.well-known/did.json`, `/openapi.json`, `/docs`, `/llms.txt` | Public | Service routes |
| POST | `/v1/offers` | `credentials/issue` | Deposit an offer (idempotent on issuer and `ref`) |
| GET | `/v1/offers` | `credentials/hold` | The holder's offers |
| GET | `/v1/offers/OFFER_ID` | Issue or hold | Issuer: summary. Holder: summary, the offer and the consent |
| POST | `/v1/offers/OFFER_ID/status` | Issue or hold | Issuer `retracted`; holder `accepted` or `declined` |
| POST | `/v1/requests` | `credentials/verify` | Deposit a signed request; answers `request_uri`, `openid4vp_url`, `wallet_url` |
| GET | `/v1/requests` | `credentials/hold` | The holder's requests |
| GET | `/v1/requests/REQUEST_ID` | Verify or hold | Verifier: status only. Holder: summary and the request JWT |
| POST | `/v1/requests/REQUEST_ID/status` | Verify or hold | Verifier `cancelled`; holder `answered` or `declined` |
| GET | `/v1/requests/REQUEST_ID/object` | Public | The request object (`application/oauth-authz-req+jwt`) while pending |
| POST | `/v1/blocks` | `credentials/hold` | Block a sender for the person |
| POST | `/v1/blocks/remove` | `credentials/hold` | Remove a block |
| GET | `/v1/blocks` | `credentials/hold` | The person's blocked senders |
| GET | `/status/ISSUER_DID/LIST_ID` | Public | The token (`application/statuslist+jwt`) |
| PUT | `/status/ISSUER_DID/LIST_ID` | `credentials/issue`, principal = `ISSUER_DID` | Publish a token with a strictly newer `iat` (else `409 stale`) |
| GET | `/v1/catalog/issuers`, `/v1/catalog/issuers/DID` | Public; optional token `credentials/hold` | Issuer entries |
| PUT, DELETE | `/v1/catalog/issuers/DID` | `credentials/issue`, principal = `DID` | Write or remove |
| GET | `/v1/catalog/verifiers`, `/v1/catalog/verifiers/DID` | Public; optional token `credentials/hold` | Verifier entries |
| PUT, DELETE | `/v1/catalog/verifiers/DID` | `credentials/verify`, principal = `DID` | Write or remove |

`HEAD` with a token is `405`. Offers and requests exist only for their two parties: anyone else gets `404`.

### Main bodies and answers

| Route | Body | Answer |
| --- | --- | --- |
| `POST /v1/offers` | `subject` (a `did:ixo:ixo1…` person), `ref`, exactly one of `credential_offer_uri` or `credential_offer`, `display` (`name`, `vct`, optional `description`; any other member is dropped), `expires_at` (at most 30 days ahead), optional `consent` | `201` (or `200` for the same body again) `{ id, status, consent, display_dropped }`, `consent` being `valid` or `none`, `display_dropped` the `display` members not stored |
| `GET /v1/offers?status=&limit=&cursor=` | — | `{ items: [OfferSummary], next_cursor }`; a summary is `{ id, issuer, sender_key, subject, ref, credential_issuer, vct, display, status, consent, created_at, expires_at, updated_at }` and never carries a code |
| `GET /v1/offers/OFFER_ID` as the subject | — | Summary plus `credential_offer_uri` or `credential_offer` (while pending) and `consent_token` (while the consent is valid) |
| `POST /v1/requests` | `subject`, `ref`, `request` (signed request object with `exp`), `purpose` (at most 140 characters), optional `expires_at` (default 15 minutes, at most 24 hours) | `201` (or `200`) `{ id, request_uri, openid4vp_url, wallet_url?, status }` |
| `GET /v1/requests?status=&limit=&cursor=` | — | `{ items: [RequestSummary], next_cursor }`; a summary is `{ id, verifier, sender_key, subject, ref, client_id, purpose, summary, response_host, status, created_at, expires_at, updated_at }` |
| `GET /v1/requests/REQUEST_ID` | — | As the verifier: `{ id, ref, status, created_at, expires_at, updated_at }`. As the subject: the summary plus `request` (the signed request object) |
| `PUT /status/ISSUER_DID/LIST_ID` | The raw token | `201` first version, `200` newer version: `{ uri, iat, exp }` |
| `GET /v1/catalog/issuers?vct=&limit=&cursor=` | — | `{ items: [{ sender_key, entry, updated_at }], next_cursor }` |
| `GET /v1/catalog/issuers/DID` | — | `{ sender_key, entry, updated_at }` for exactly that DID |
| `PUT /v1/catalog/issuers/DID` | The entry (`did` = the path), with optional `logo_uri`, `color` and `types[].display` | `201` new, `200` replaced: `{ sender_key, entry, updated_at, display_dropped }`; `DELETE` answers `204` |
| `PUT /v1/catalog/verifiers/DID` | The entry (`did` = the path), with optional `logo_uri` and `color` | As for issuers |
| `POST /v1/blocks` | `{ "sender": "SENDER_DID" }` | `200 { sender, created_at, closed }`. Idempotent. Also closes every offer and request from that sender waiting for the person, as declined; `closed` is how many |
| `POST /v1/blocks/remove` | `{ "sender": "SENDER_DID" }` | `200 { sender, removed }`. Idempotent; `removed` is `false` when there was no block |
| `GET /v1/blocks?limit=&cursor=` | — | `{ items: [{ sender, created_at }], next_cursor }` |

A deposit (`POST /v1/offers`, `POST /v1/requests`) for a person who blocked the sender answers `403 blocked`. Nothing is stored and no webhook is sent. A block holds for exactly the DID named: blocking `did:web:a.example.com` does not block `did:web:b.example.com` or `did:web:example.com:x`. The worker checks the depositor only, not the web address an offer names. One list covers issuers and verifiers. Every block, unblock and refused deposit is recorded in the worker's audit trail.

Each offer and request carries `sender_key`, the sender's DID in canonical form: look up the sender's name and look with it in the catalog. `limit` is 1 to 100 (default 50). Offer statuses: `pending`, `accepted`, `declined`, `retracted`, `expired`. Request statuses: `pending`, `answered`, `declined`, `cancelled`, `expired`.

## Limits

| Limit | Value |
| --- | --- |
| Rate, per address | Counts only failed work, after the work: a failed public read (a 4xx other than 429, an unknown route) or a token that fails authentication (bad shape, bad signature, unknown DID, or your own `did:web` document that cannot be fetched). Over 600 a minute per address (an IPv6 address by its /64), such failures are answered `429`. A successful public read and a valid token are never refused or counted by address, so callers that share one address do not block each other |
| Rate, authenticated routes | Per principal, 300 a minute, keyed by the invoker and counted as soon as its signature verifies (so requests that then fail count too) |
| Catalog reads with a token | An optional `credentials/hold` token moves the limit from the address to the principal and makes the answer `Cache-Control: private`; an invalid token is `401`, never a silent fall back |
| Waiting per sender per person | 2 offers and, separately, 2 requests (`SENDER_PERSON_PENDING_CAP`) |
| Waiting per sender, across all people | `SENDER_PENDING_CAP` (default 2,000) |
| Senders | One rule for every sender; every DID is its own sender, exactly as written |
| Blocks | 500 per person |
| Bodies | Offers 16 KB, requests 18 KB, status lists 64 KB, catalog entries 32 KB |
| Status lists | 20 per issuer |
| Storage guard | Near the worker's storage limit: no new status list and no new or larger catalog entry (`507 storage_full`); updates of what you already publish still work |

## Errors

Every non-2xx answer has one shape. Branch on `error`; `message` is a plain sentence for people; the technical cause stays in the worker's log under the same `reference`, which is also the `X-Request-Id` header. Quote the `reference` when you ask for support. A call without a token, for example:

```bash
curl -s https://dev.credentials.ixo.earth/v1/offers
```

```json
{
  "error": "unauthenticated",
  "message": "We couldn't confirm who is calling. Sign in again and send a new request.",
  "reference": "bf3b5664-f07c-4d7b-bf41-5e655cc5fb03"
}
```

A deposit for a person who blocked you:

```json
{
  "error": "blocked",
  "message": "This person is not accepting offers or requests from you.",
  "reference": "0b6f3c2e-2c1d-4f5e-9a51-6f1f3f2a9d10"
}
```

| Code | Status | Meaning |
| --- | --- | --- |
| `invalid_request` | 400 | The body or the request object failed validation, including an invalid or revoked offer consent |
| `unknown_subject` | 400 | The `subject` is not a `did:ixo` person who exists |
| `unauthenticated` | 401 | Missing, malformed, oversized, expired, replayed or unbound invocation, wrong audience, unknown DID, or a signer that is not `did:ixo` or `did:web` |
| `forbidden` | 403 | Authenticated but not permitted (ability, resource, revoked delegation) |
| `blocked` | 403 | A deposit for a person who blocked the sender: "This person is not accepting offers or requests from you." |
| `not_found` | 404 | Not found, not yours, or a request object no longer pending |
| `method_not_allowed` | 405 | `HEAD` on an authenticated route |
| `ref_in_use` | 409 | The same `ref` with a different body |
| `stale` | 409 | A status list whose `iat` is not newer than the stored one |
| `too_many_lists` | 409 | The issuer already has 20 status lists |
| `too_many_blocks` | 409 | The person already has 500 blocks |
| `offer_gone`, `request_gone` | 410 | No longer pending |
| `too_large` | 413 | Body over its cap |
| `unsupported_media_type` | 415 | Wrong content type |
| `rate_limited` | 429 | Over a rate limit; `Retry-After` |
| `too_many_pending` | 429 | A waiting limit: 2 per sender per person (offers and requests counted separately), or `SENDER_PENDING_CAP` per sender (the message says which) |
| `internal_error` | 500 | Unexpected error |
| `unavailable` | 503 | IXO Blocksync, a DID host, the UCAN store or the database is down, or the offer key is missing; `Retry-After` |
| `storage_full` | 507 | The worker is near its storage limit: a new status list or a new or larger catalog entry is refused; updates of existing ones still work |

What to do with each class: fix the request on `400`, `413` and `415`; sign a fresh, request-bound invocation on `401`; treat `403 blocked` as final for that person; wait for `Retry-After` on `429 rate_limited` and `503`; on `429 too_many_pending`, cancel or retract items you no longer need, or wait.

## Webhook to the Auth Hub

The worker notifies the Hub, and only the Hub, of `offer.created`, `offer.retracted`, `request.created` and `request.cancelled`. The body carries no secret; the Hub fetches the details itself, as the person:

```json
{ "id": "evt_6f1c0e3a9b7d4c2e8a6f0b1d3c5e7a9f", "type": "offer.created", "subject": "did:ixo:ixo1qqqsyqcyq5rqwzqfpg9scrgwpugpzysn4uq0hv", "object_id": "OFFER_ID", "created_at": 1793966400 }
```

`object_id` is the offer or request id, never the sender's `ref`. The header `x-ixo-signature: t=UNIX_TIME,kid=WORKER_KEY_ID,sig=BASE64URL_SIGNATURE` is an Ed25519 signature over `UNIX_TIME.RAW_BODY` with the worker's `did:web` key, signed afresh at every attempt. Receivers verify it with the kit's `verifyWebhook`, refuse a signature older than 300 seconds or more than 60 seconds in the future, and de-duplicate by `id`. Failed deliveries are retried at about 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours, then given up; the item stays pending for the person. Deliveries can arrive out of order.

## Auth Hub routes

Under `HUB_URL/api/credentials`, for the Hub's own pages. Authenticated with the Hub session (same origin only) or a short UCAN the person signs themselves; rate limited per person. This is a summary: the Hub's own documentation is the contract.

| Route | Purpose |
| --- | --- |
| `GET /overview` | Settings, Vault state (`ok`, `preparing`, `unavailable`, `needs_attention`), credentials, waiting offers and requests, standing consents, `blocks: { count }`, latest activity. Reads only the Hub's cache; `?sync=1` refreshes it (at most once a minute per person) |
| `GET /items/CREDENTIAL_ID` | One stored credential, its claims and whether it is revoked |
| `GET /offers/OFFER_ID`, `POST /offers/OFFER_ID/accept`, `POST /offers/OFFER_ID/decline` | Look at, save or decline an offer. `accept` answers `202` when the issuer defers; a repeat of a finished action answers `200` with `already: true`. `decline` takes `{ block? }`: when `true`, it declines and then blocks the issuer |
| `GET /requests/REQUEST_ID`, `POST /requests/REQUEST_ID/approve`, `POST /requests/REQUEST_ID/decline` | Look at, share or decline. The review lists what will be shared, with values, and what is not shared (`withheld`); `approve` takes `{ remember?, credentials }` (`remember` creates a standing consent; `credentials` maps each query to the credential the person chose). `decline` takes `{ block? }`, as for offers |
| `DELETE /consents/CONSENT_ID` | Stop a standing consent |
| `GET /blocks` | `{ blocks: [{ party, since }] }` |
| `POST /blocks`, `POST /blocks/remove` | `{ did }`: block or unblock a sender, calling the worker as the person. Answer `{ ok: true, already?: true }` |
| `PUT /settings` | `{ autoStore }` on or off |
| `GET /activity?cursor=` | The activity log |
| `POST /events` | The worker's webhook (signature, no session) |

Every party in an answer is `{ did, name?, mark: { color, monogram, logo? } }`, taken from the party's catalog entry, else a neutral mark. Errors are `{ error, message, reference, retryAfter? }`:

| Code | Status | When |
| --- | --- | --- |
| `signed_out`, `unauthorized` | 401 | No session; a UCAN that is not the person's own short one |
| `cross_origin` | 403 | A session call from another origin |
| `bad_request` | 400 | Validation failed |
| `not_found` | 404 | Not this person's, or not there |
| `offer_gone`, `request_gone` | 410 | Closed some other way |
| `busy` | 409 | Another attempt holds the item |
| `cannot_share` | 409 | What would be shared is no longer what the page showed, or nothing fits |
| `vault_needs_attention` | 409 | The account or Vault room is in a state the Hub must not touch |
| `not_supported` | 409 | A transaction-code offer, or the Vault index for the type is full |
| `too_many_blocks` | 409 | The person already has 500 blocks |
| `issuer_unavailable`, `credential_rejected`, `verifier_unavailable` | 502 | The issuer or service could not be reached, or the issued credential failed the checks |
| `vault_unavailable`, `service_unavailable`, `not_enabled` | 503 | The homeserver, the Credentials worker or the feature is unavailable |
| `rate_limited` | 429 | Over a per-person limit; `Retry-After` |
| `try_again` | 500 | Anything else |

## Related guides

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