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 emptycredentialConfigurationIdsarray. Themessagelists the specific fields that failed.Unknown URI protocol (
POST /holders/:holderId/process) — theuriyou submitted is neither a credential offer nor an authorization request. The message isUnknown URI protocol. Expected openid-credential-offer:// or openid4vp://. Pass the fullopenid-credential-offer://...URI returned by a credential offer, or theopenid4vp://...(orhttps://...) 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 isHolder has no DIDs created. Cannot accept credential offer.Create the holder with a DID method (POST /agent/holderwith{ 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 generic401 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 nokid, so the signing key cannot be selected. Message:Access token header is missing kid. Ensure your OIDC provider includes akidin the JWT header (Keycloak does this by default).Signing key not found in JWKS — the token's
kiddoes not match any key published atOIDC_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_URLmisconfigured — 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 examplehttps://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
adminrole and the token'srealm_access.roles(or client roles inresource_access) does not include it. Theadminrole 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 anyholderIdthe caller did not create. Message:Access to holder denied. AholderIdthat does not exist at all is indistinguishable from one owned by another user — both return this403. A user can only read, decode, or process URIs through holders they created; list your own withGET /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:webdocument (GET /:uuid/did.jsonorGET /: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 exampleCould 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
messageoutside 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
500rather than404.
Startup & configuration errors
The service validates its configuration at boot and refuses to start when required variables are missing.
Missing required env vars —
ISSUER_BASE_URL,VERIFIER_BASE_URL, andDATABASE_URLare mandatory. If any is absent the process exits withInvalid environment variables:followed by the offending variable, for example- ISSUER_BASE_URL must be defined.Missing JWKS configuration — unless
AUTH_DISABLED=true,OIDC_JWKS_URLmust be set, otherwise startup fails withOIDC 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 orGET /versionfor{ "name": "ssi-core", "version": "..." }. Neither requires authentication.Authentication problems? Obtain a valid
admintoken from your OIDC provider and send it asAuthorization: Bearer <token>. Locally, setAUTH_DISABLED=trueto skip token handling while you debug other behavior.Inspect the contract. The interactive API explorer at
GET /api-docsand the machine-readable spec atGET /api-docs-jsonshow every endpoint, request body, and response schema.Read the envelope. The
pathandmessagefields identify exactly which request failed and why; for validation errors themessagearray names each invalid field.
Last updated