# IXO Credentials > The central service of IXO Credentials (SD-JWT VC, OpenID4VCI, OpenID4VP, Token Status List): > an offer inbox (issuers leave credential offers for a holder's wallet), a presentation broker > (verifiers leave signed OpenID4VP requests for a holder), a status-list registry (issuers > publish their own signed status lists; credentials point here) and a public catalog (what each > issuer says it issues, what each verifier says it accepts). It never holds a claim and never > signs a credential. - [OpenAPI 3](https://credentials.ixo.earth/openapi.json): the complete contract, every route and schema - [API reference](https://credentials.ixo.earth/docs) - [DID document](https://credentials.ixo.earth/.well-known/did.json): `did:web:credentials.ixo.earth` ## Routes - `GET /` (public): service name, version and links - `GET /health` (public): health (D1 reachable) - `GET /.well-known/did.json` (public): this worker's did:web document (the UCAN audience and webhook signer) - `GET /openapi.json` (public): the OpenAPI 3 contract - `GET /docs` (public): API reference (Scalar) - `GET /llms.txt` (public): this summary - `POST /v1/offers` (credentials/issue): deposit an OpenID4VCI credential offer for a holder (idempotent on ref); `display` is `{ name, vct, description? }`, anything else is dropped and listed in `display_dropped` - `GET /v1/offers` (credentials/hold): the holder's offers (summaries, never the code) - `GET /v1/offers/{id}` (credentials/issue or credentials/hold): one offer: issuer summary, or holder summary + credential_offer_uri or credential_offer + consent - `POST /v1/offers/{id}/status` (credentials/issue or credentials/hold): issuer: retracted; holder: accepted or declined - `POST /v1/requests` (credentials/verify): deposit a signed OpenID4VP request for a holder (idempotent on ref); answers request_uri, openid4vp_url and wallet_url - `GET /v1/requests` (credentials/hold): the holder's presentation requests - `GET /v1/requests/{id}` (credentials/verify or credentials/hold): verifier: status only; holder: summary + request object - `POST /v1/requests/{id}/status` (credentials/verify or credentials/hold): verifier: cancelled; holder: answered or declined - `GET /v1/requests/{id}/object` (public): the request object (OpenID4VP request_uri), while pending - `POST /v1/blocks` (credentials/hold): block a sender (`{ sender }`; idempotent; closes that sender's waiting offers and requests for you as declined) - `POST /v1/blocks/remove` (credentials/hold): unblock a sender (`{ sender }`; idempotent) - `GET /v1/blocks` (credentials/hold): your blocked senders, newest first - `GET /status/{issuerDid}/{listId}` (public): a Token Status List token (application/statuslist+jwt) - `PUT /status/{issuerDid}/{listId}` (credentials/issue): publish your newer signed status list token - `GET /v1/catalog/issuers` (public; optional token: credentials/hold): issuer entries (?vct= filter, cursor pagination) - `GET /v1/catalog/issuers/{did}` (public; optional token: credentials/hold): one issuer entry - `PUT /v1/catalog/issuers/{did}` (credentials/issue): write your issuer entry - `DELETE /v1/catalog/issuers/{did}` (credentials/issue): remove your issuer entry - `GET /v1/catalog/verifiers` (public; optional token: credentials/hold): verifier entries (?vct= filter, cursor pagination) - `GET /v1/catalog/verifiers/{did}` (public; optional token: credentials/hold): one verifier entry - `PUT /v1/catalog/verifiers/{did}` (credentials/verify): write your verifier entry - `DELETE /v1/catalog/verifiers/{did}` (credentials/verify): remove your verifier entry ## Auth - `Authorization: Bearer `, addressed to `did:web:credentials.ixo.earth`, lifetime at most 300 s, single use (a replay is refused). - Token shape: at most 24 KB, 12 blocks, a delegation chain at most 4 deep, at most 4 proofs per UCAN, each proof once (else 401). Every signer in the chain is a `did:ixo` or `did:web`; a `did:key` anywhere is refused (401). - Every invocation carries a fact `{ "method": "", "path": "" }` naming the one request it is for. Without it, or for another request: 401. - Ability: `credentials/issue`, `credentials/verify`, `credentials/hold` or `*`. - Resource `ixo:credentials`: you act as yourself (the root of the proof chain); an entity acts as itself by signing with a key on its own DID document. Resource `ixo:credentials/`: a controller of the entity, or a member with voting weight > 0 of a controlling DAO, acts as the entity for CATALOG writes only; offers, requests and status lists need the entity's own key (403 otherwise). - Delegated invocations may only narrow what they were given; revoked delegations (UCAN store registry) are refused. - Catalog GETs take an OPTIONAL token (ability `credentials/hold` or `*` on `ixo:credentials`): with it they are limited per principal instead of per IP and answered `Cache-Control: private`; an invalid token is 401, never a silent fall back. - Limits: the per-IP limit (an IPv6 address by its /64) counts only FAILED work, after the work: a failed public read, or a token that fails authentication (bad shape, a bad signature, an unknown DID, or your own did:web document that cannot be fetched, answered 503); once the IP is over its limit, such a failure is answered 429. A valid call is never refused or counted by IP, and DID documents are always looked up. Authenticated routes are limited per signer (the invoker DID), counted as soon as the invocation's signature verifies, so a request that then fails (ability, membership, revocation, replay) counts too. `HEAD` on an authenticated route is 405. - 400 invalid (`invalid_request`, `unknown_subject`), 401 cannot authenticate, 403 not permitted (`forbidden`, or `blocked`: the person blocked you), 404 not found or not yours, 405 `HEAD` with a token, 409 conflict (`ref_in_use`, `stale`, `too_many_lists`, `too_many_blocks`), 429 slow down (`rate_limited`, `too_many_pending`), 503 a dependency is down, 507 `storage_full` (no new status list and no new or larger catalog entry while the service is near its storage limit; updates of what you already publish still work). 429 and 503 carry `Retry-After`. ## Errors Every non-2xx answer is `{ "error": "", "message": "", "reference": "" }`; branch on `error`, quote `reference` (also the `X-Request-Id` header) to support: `invalid_request` (400), `unauthenticated` (401), `forbidden` (403), `not_found` (404), `method_not_allowed` (405), `unknown_subject` (400), `ref_in_use` (409), `stale` (409), `too_many_lists` (409), `offer_gone` (410), `request_gone` (410), `too_large` (413), `unsupported_media_type` (415), `rate_limited` (429), `too_many_pending` (429), `blocked` (403), `too_many_blocks` (409), `unavailable` (503), `storage_full` (507), `internal_error` (500). ## Notes - Put DIDs in paths exactly as written (`did:web:host%3Aport` stays as is). A `did:web` follows the did:web method spec (`did:web:` → `https:///.well-known/did.json`, `did:web:::` → `https:///a/b/did.json`; any subdomain, a port as `%3A`, any path) and must be in canonical form everywhere (lower-case host, no trailing dot, no percent-encoding in the host except `%3A` before a port other than 443, an IDN in punycode, no IP address; in a path segment upper-case hex only, no percent-encoded letter, digit, `-`, `.`, `_` or `~`, and no `.well-known` segment); any other spelling is refused. A `did:ixo` is a person (`did:ixo:ixo1…`, lower case) or an entity (`did:ixo:entity:` + 32 lower-case hex digits); nothing else. - Every DID is its own sender, exactly as written: `did:web:example.com`, `did:web:a.example.com` and `did:web:example.com:user:alice` are three senders, each with its own caps, block and catalog entry. Each offer and request carries `sender_key` (the sender's DID). Look the sender's name and look up by it (`GET /v1/catalog/issuers/{did}` and `/verifiers/{did}` answer exactly that DID's entry). - Timestamps are unix seconds (integers). Lists use `limit` (at most 100) and the opaque `next_cursor`. - Offers and requests are for a `did:ixo` person who exists on chain (else 400 `unknown_subject`). - Pending caps, the same for every sender (429 `too_many_pending`): at most 2 waiting offers and 2 waiting requests per person from one sender (default 2), and at most 2000 waiting offers and 2000 waiting requests per sender in total (default 2000). A repeat of an item you already deposited (same ref, same body) is answered 200 even when a cap is full. - Blocking: a person blocks a sender with `POST /v1/blocks` `{ "sender": "" }` (their waiting items from it close as declined), unblocks with `POST /v1/blocks/remove`, lists with `GET /v1/blocks`; at most 500 per person. A blocked sender's deposit is 403 `blocked` ("This person is not accepting offers or requests from you."), nothing is stored. A block is of exactly the DID named (blocking one did:web never blocks another DID on the same domain); one list for issuers and verifiers. - Status lists: a new version must carry an `iat` strictly newer than the stored one (else 409 `stale`); `iat` at most 60 s ahead, `exp` after `iat` and at most 400 days ahead, whole seconds; at most 20 lists per issuer (409 `too_many_lists`); a list 30 days past its `exp` is deleted; near the storage limit a NEW list is 507 `storage_full` (replacing an existing one still works). - `POST /v1/requests` answers `openid4vp_url` (`openid4vp://?client_id=…&request_uri=…`) for any wallet, and `wallet_url` for the Auth Hub. - Catalog entries are what each DID says about itself, not a trust list; pin issuer DIDs. - Card look, names and logos come only from catalog entries (an offer or request cannot change them): issuer `types[].display` takes `background_color`, `text_color` (`#rrggbb` only, stored lower case), `logo_uri`, `background_image_uri` (https on a public host, at most 512 characters, no user name or password); issuer and verifier entries take `logo_uri` and `color` for their mark. An offer's `display` is `{ name, vct, description? }`: anything else is dropped and named in `display_dropped`. A verifier's `purpose` (its own words for why it asks) stays on each request. Every image address must be https on the default port with no query string and no fragment (a per-person query string would make the image a tracking pixel). A member that is not valid (or unknown inside `display`) is dropped, never a 400, and named in the answer's `display_dropped`. Reads return what was stored. This worker never fetches the images; wallets do (png, jpeg or webp; never SVG). Names follow OpenID4VCI credential display metadata (`logo.uri` → `logo_uri`, `background_image.uri` → `background_image_uri`). - Webhooks to the Auth Hub are signed (`x-ixo-signature: t=…,kid=…,sig=…`) with this worker's did:web key.