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 and 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, /openapi.json, /llms.txt | https://dev.auth.ixo.earth |
| Testnet | https://test.credentials.ixo.earth | did:web:test.credentials.ixo.earth | /docs, /openapi.json, /llms.txt | https://test.auth.ixo.earth |
| Mainnet | https://credentials.ixo.earth | did:web:credentials.ixo.earth | /docs, /openapi.json, /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 (0.1.1), with @ixo/ucan for the invocations.
Authentication
Every write, every issuer, verifier and holder read, and optionally a catalog read, carries one header:
Authorization: Bearer BASE64_CAR_UCAN_INVOCATIONThe UCAN invocation must:
- be addressed to the worker's
did:web(served atCREDENTIALS_WORKER_URL/.well-known/did.json; derived from the worker's configured host, never the request'sHostheader); - 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:ixoordid:web(any other method,did:keyincluded, 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
https://dev.credentials.ixo.earth
https://test.credentials.ixo.earth
https://credentials.ixo.earthcurl -s https://dev.credentials.ixo.earth/{"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 (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:
curl -s https://dev.credentials.ixo.earth/v1/offers{
"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:
{
"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:
{ "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 |