For the complete documentation index, see llms.txt. This page is also available as Markdown.

Error Handling and Troubleshooting

The service returns errors through a single global exception filter, so every error response has the same shape regardless of which endpoint produced it. This page describes that shape, then catalogs the errors you are most likely to encounter, organized by HTTP status code, and finishes with startup and connectivity issues.

Error response shape

All errors are returned as JSON with a consistent envelope:

{
  "statusCode": 403,
  "message": "Insufficient role",
  "path": "/agent/dids",
  "timestamp": "2026-06-22T10:15:30.123Z"
}
  • statusCode — the HTTP status code, also reflected in the response status line.

  • message — a human-readable description; for validation errors this may be an array of messages.

  • path — the request path that produced the error.

  • timestamp — ISO-8601 time the error was generated.

Unhandled server errors are returned as 500 with a generic Internal server error message in production; details are not leaked to the client.

400 Bad Request

Returned when the request body, query, or URI is malformed or semantically invalid.

  • Validation failure — a required field is missing or has the wrong type. For example, creating a schema without name/version, or a credential offer with an empty credentialConfigurationIds array. The message lists the specific fields that failed.

  • Unknown URI protocol (POST /holders/:holderId/process) — the uri you submitted is neither a credential offer nor an authorization request. The message is Unknown URI protocol. Expected openid-credential-offer:// or openid4vp://. Pass the full openid-credential-offer://... URI returned by a credential offer, or the openid4vp://... (or https://...) authorization request URI from a verifier.

  • Holder has no DIDs (POST /holders/:holderId/process) — the wallet tenant has no DID with which to bind a claimed credential. The message is Holder has no DIDs created. Cannot accept credential offer. Create the holder with a DID method (POST /agent/holder with { method: "key" } or { method: "web" }) before claiming offers.

  • Unknown property in the body — the global validation pipe whitelists request properties, so a JSON body containing a property that is not part of the endpoint's schema is rejected with a message like property X should not exist.

401 Unauthorized

Returned by authentication, before any role check, when the Bearer token cannot be validated. Tokens must be RS256-signed and presented as Authorization: Bearer <JWT>.

  • Missing token — no Authorization: Bearer <JWT> header is sent; the request is rejected with a generic 401 Unauthorized.

  • Malformed token — a token is present but is not a well-formed JWT (it cannot be decoded). Message: Invalid access token.

  • Missing kid — the token header has no kid, so the signing key cannot be selected. Message: Access token header is missing kid. Ensure your OIDC provider includes a kid in the JWT header (Keycloak does this by default).

  • Signing key not found in JWKS — the token's kid does not match any key published at OIDC_JWKS_URL. This happens after a provider key rotation (wait for the 10-minute key cache to refresh) or when the token was issued by a different provider/realm than the one configured.

  • Expired token — expiration is always enforced. Request a fresh token from your OIDC provider.

  • OIDC_JWKS_URL misconfigured — the JWKS endpoint is unreachable or returns no usable keys, so no token can be verified. Confirm the URL points at your provider's JWKS document (for example https://keycloak.example.com/realms/<realm>/protocol/openid-connect/certs).

For local development you can bypass authentication entirely with AUTH_DISABLED=true, which treats every request as an authenticated admin principal. Never enable this outside local development.

403 Forbidden

Returned when the token is valid but the caller is not allowed to perform the operation.

  • Insufficient role — the route requires the admin role and the token's realm_access.roles (or client roles in resource_access) does not include it. The admin role is enforced on the /agent/* (DID, issuer, verifier, holder management) and /holders/:holderId/* (wallet) routes. Message: Insufficient role.

  • Holder not owned by the caller (/holders/:holderId/*) — the ownership guard denies access to any holderId the caller did not create. Message: Access to holder denied. A holderId that does not exist at all is indistinguishable from one owned by another user — both return this 403. A user can only read, decode, or process URIs through holders they created; list your own with GET /agent/holders.

404 Not Found

Returned when the addressed resource does not exist.

  • Unknown schema id — message: Schema not found. Confirm the id returned when the schema was created.

  • Unknown hosted did:web document (GET /:uuid/did.json or GET /:uuid/.well-known/did.json) — message: DID Document not found.

Two related lookups behave differently: GET /agent/did/:did returns 200 with a null body for a DID that was not created by this service, and unknown issuer, verifier, or session ids currently surface as 500 errors rather than 404.

429 Too Many Requests

A global limit of 60 requests per 60 seconds applies to the API. Exceeding it returns 429. Throttle or batch your calls and retry after a short delay. Serving of hosted did:web documents is exempt, so external DID resolution is never rate-limited.

500 Internal Server Error

Errors raised outside the HTTP layer — including protocol failures inside a wallet flow — are converted to 500 by the global exception filter. When NODE_ENV is production, the message is replaced with a generic Internal server error; otherwise the underlying error message is returned. In production, check the server logs for the real cause.

  • No matching credential for a presentation (POST /holders/:holderId/process) — the holder wallet has no stored credential that satisfies the verifier's DCQL or Presentation Exchange query. Outside production the message is the underlying error, for example Could not find the required credentials for the presentation submission. Claim the required credential (for example [email protected]:sd-jwt) into the wallet first, then retry the authorization request.

  • Upstream issuer/verifier rejection — while processing an offer or authorization request, the remote issuer or verifier returned an error (expired or already-consumed offer, wrong PIN, rejected presentation). The upstream reason appears in message outside production; in production it is only in the logs.

  • Unknown issuer, verifier, or session id — looking up an id the service does not know currently surfaces as 500 rather than 404.

Startup & configuration errors

The service validates its configuration at boot and refuses to start when required variables are missing.

  • Missing required env varsISSUER_BASE_URL, VERIFIER_BASE_URL, and DATABASE_URL are mandatory. If any is absent the process exits with Invalid environment variables: followed by the offending variable, for example - ISSUER_BASE_URL must be defined.

  • Missing JWKS configuration — unless AUTH_DISABLED=true, OIDC_JWKS_URL must be set, otherwise startup fails with OIDC configuration missing. Set OIDC_JWKS_URL.

  • Database connectivity — connection failures come from DATABASE_URL. Verify the host, port, credentials, and database name, and that the database is reachable from the service and migrations have run.

  • Insecure HTTP blocked — OpenID4VCI and OpenID4VP flows require HTTPS, so insecure HTTP base URLs are rejected by default. For local development only, set ALLOW_INSECURE_HTTP=true. Never set it in production.

Troubleshooting tips

  • Is the service up? Call GET / for a hello string or GET /version for { "name": "ssi-core", "version": "..." }. Neither requires authentication.

  • Authentication problems? Obtain a valid admin token from your OIDC provider and send it as Authorization: Bearer <token>. Locally, set AUTH_DISABLED=true to skip token handling while you debug other behavior.

  • Inspect the contract. The interactive API explorer at GET /api-docs and the machine-readable spec at GET /api-docs-json show every endpoint, request body, and response schema.

  • Read the envelope. The path and message fields identify exactly which request failed and why; for validation errors the message array names each invalid field.

Last updated