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

NetworkBase URLWorker DID (UCAN audience)Live referenceAuth Hub
Devnethttps://dev.credentials.ixo.earthdid:web:dev.credentials.ixo.earth/docs, /openapi.json, /llms.txthttps://dev.auth.ixo.earth
Testnethttps://test.credentials.ixo.earthdid:web:test.credentials.ixo.earth/docs, /openapi.json, /llms.txthttps://test.auth.ixo.earth
Mainnethttps://credentials.ixo.earthdid:web:credentials.ixo.earth/docs, /openapi.json, /llms.txthttps://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_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).

AbilityFor
credentials/issueIssuers: offers, status lists, issuer catalog entry
credentials/verifyVerifiers: requests, verifier catalog entry
credentials/holdHolders: their own offers, requests and block list; optional token on catalog reads
*All of the above
ResourceCaller acts asAllowed for
ixo:credentialsThe root of the proof chain: a person, a service, or an entity signing with a key on its own DID documentEvery route
ixo:credentials/ENTITY_DIDThat entity, when the root is a controller of it or a member of a controlling DAO group with voting weight above zeroCatalog 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.earth
curl -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

MethodPathAuthWhat
GET/, /health, /.well-known/did.json, /openapi.json, /docs, /llms.txtPublicService routes
POST/v1/offerscredentials/issueDeposit an offer (idempotent on issuer and ref)
GET/v1/offerscredentials/holdThe holder's offers
GET/v1/offers/OFFER_IDIssue or holdIssuer: summary. Holder: summary, the offer and the consent
POST/v1/offers/OFFER_ID/statusIssue or holdIssuer retracted; holder accepted or declined
POST/v1/requestscredentials/verifyDeposit a signed request; answers request_uri, openid4vp_url, wallet_url
GET/v1/requestscredentials/holdThe holder's requests
GET/v1/requests/REQUEST_IDVerify or holdVerifier: status only. Holder: summary and the request JWT
POST/v1/requests/REQUEST_ID/statusVerify or holdVerifier cancelled; holder answered or declined
GET/v1/requests/REQUEST_ID/objectPublicThe request object (application/oauth-authz-req+jwt) while pending
POST/v1/blockscredentials/holdBlock a sender for the person
POST/v1/blocks/removecredentials/holdRemove a block
GET/v1/blockscredentials/holdThe person's blocked senders
GET/status/ISSUER_DID/LIST_IDPublicThe token (application/statuslist+jwt)
PUT/status/ISSUER_DID/LIST_IDcredentials/issue, principal = ISSUER_DIDPublish a token with a strictly newer iat (else 409 stale)
GET/v1/catalog/issuers, /v1/catalog/issuers/DIDPublic; optional token credentials/holdIssuer entries
PUT, DELETE/v1/catalog/issuers/DIDcredentials/issue, principal = DIDWrite or remove
GET/v1/catalog/verifiers, /v1/catalog/verifiers/DIDPublic; optional token credentials/holdVerifier entries
PUT, DELETE/v1/catalog/verifiers/DIDcredentials/verify, principal = DIDWrite 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

RouteBodyAnswer
POST /v1/offerssubject (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 consent201 (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/requestssubject, 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_IDThe raw token201 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/DIDThe entry (did = the path), with optional logo_uri, color and types[].display201 new, 200 replaced: { sender_key, entry, updated_at, display_dropped }; DELETE answers 204
PUT /v1/catalog/verifiers/DIDThe entry (did = the path), with optional logo_uri and colorAs 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

LimitValue
Rate, per addressCounts 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 routesPer 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 tokenAn 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 person2 offers and, separately, 2 requests (SENDER_PERSON_PENDING_CAP)
Waiting per sender, across all peopleSENDER_PENDING_CAP (default 2,000)
SendersOne rule for every sender; every DID is its own sender, exactly as written
Blocks500 per person
BodiesOffers 16 KB, requests 18 KB, status lists 64 KB, catalog entries 32 KB
Status lists20 per issuer
Storage guardNear 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"
}
CodeStatusMeaning
invalid_request400The body or the request object failed validation, including an invalid or revoked offer consent
unknown_subject400The subject is not a did:ixo person who exists
unauthenticated401Missing, malformed, oversized, expired, replayed or unbound invocation, wrong audience, unknown DID, or a signer that is not did:ixo or did:web
forbidden403Authenticated but not permitted (ability, resource, revoked delegation)
blocked403A deposit for a person who blocked the sender: "This person is not accepting offers or requests from you."
not_found404Not found, not yours, or a request object no longer pending
method_not_allowed405HEAD on an authenticated route
ref_in_use409The same ref with a different body
stale409A status list whose iat is not newer than the stored one
too_many_lists409The issuer already has 20 status lists
too_many_blocks409The person already has 500 blocks
offer_gone, request_gone410No longer pending
too_large413Body over its cap
unsupported_media_type415Wrong content type
rate_limited429Over a rate limit; Retry-After
too_many_pending429A waiting limit: 2 per sender per person (offers and requests counted separately), or SENDER_PENDING_CAP per sender (the message says which)
internal_error500Unexpected error
unavailable503IXO Blocksync, a DID host, the UCAN store or the database is down, or the offer key is missing; Retry-After
storage_full507The 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.

RoutePurpose
GET /overviewSettings, 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_IDOne stored credential, its claims and whether it is revoked
GET /offers/OFFER_ID, POST /offers/OFFER_ID/accept, POST /offers/OFFER_ID/declineLook 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/declineLook 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_IDStop 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 /eventsThe 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? }:

CodeStatusWhen
signed_out, unauthorized401No session; a UCAN that is not the person's own short one
cross_origin403A session call from another origin
bad_request400Validation failed
not_found404Not this person's, or not there
offer_gone, request_gone410Closed some other way
busy409Another attempt holds the item
cannot_share409What would be shared is no longer what the page showed, or nothing fits
vault_needs_attention409The account or Vault room is in a state the Hub must not touch
not_supported409A transaction-code offer, or the Vault index for the type is full
too_many_blocks409The person already has 500 blocks
issuer_unavailable, credential_rejected, verifier_unavailable502The issuer or service could not be reached, or the issued credential failed the checks
vault_unavailable, service_unavailable, not_enabled503The homeserver, the Credentials worker or the feature is unavailable
rate_limited429Over a per-person limit; Retry-After
try_again500Anything else