Skip to content

API Reference

Hand-written reference for QAuth’s HTTP endpoints. The authoritative, always-current contract is the interactive OpenAPI / Swagger UI served at /docs on any running instance — this page is a stable, linkable companion. The same contract is also published as a static file: openapi.json.

For step-by-step flows with copy-paste curl, see the OAuth 2.1 Flow guide; for working client code, see Code Examples; for the login/consent/resume screens an end user sees, see Hosted UI.

Conventions

  • Base URL / issuer: http://localhost:3000 (your JWT_ISSUER).
  • First-party auth, client-management, and hosted-UI bodies are JSON (application/json) with camelCase fields. OAuth wire endpoints (the OAuth 2.1 section below) use application/x-www-form-urlencoded with snake_case per the RFCs.
  • Access tokens are EdDSA (Ed25519) JWTs; verify against GET /.well-known/jwks.json.
  • Path parameters are written in OpenAPI’s {param} style below (matching openapi.json), not the :param style the Fastify route files use internally.

Errors share a single envelope:

{ "error": "human-readable message", "statusCode": 400, "code": "OPTIONAL_CODE" }

Schema-validation failures use:

{
"error": "Validation error",
"code": "VALIDATION_ERROR",
"statusCode": 400,
"details": [{ "path": "/grant_type", "message": "…" }]
}

details carries one entry per rejected field: path names it, message says what is wrong with it. Nothing else — the validator’s own issue objects also carry the schema path, the rule keyword and a parameter bag, and those describe how QAuth is built rather than what it accepts, so they are not returned. At most 20 entries are returned. An array longer than its limit is one entry for the array as a whole; its elements are not checked.

OAuth endpoints additionally return the standard OAuth error codes documented in OAuth 2.1 Flow → Errors (e.g. invalid_grant, invalid_client, invalid_scope, invalid_target). On those endpoints error is the registered RFC 6749 §5.2 token and any human-readable detail is a separate error_description:

{
"error": "invalid_client",
"error_description": "CIMD document is not valid JSON",
"code": "INVALID_CLIENT",
"statusCode": 401
}

error_description is omitted where describing the failure would be an enumeration oracle — client authentication failures answer with the bare token.

StatusMeaning
400Malformed request / validation error
401Missing, malformed, or invalid bearer token / session
403Authenticated but not permitted (e.g. insufficient scope)
404Resource not found
409Conflict (e.g. a resource that must be unique)
429Rate limited

Unauthenticated operational endpoints.

Root endpoint. Returns a fixed greeting to verify the server is running.

200 OK: { "message": "Hello API" }

Liveness/readiness probe. Checks database and Redis connectivity.

200 OK (both dependencies reachable):

{
"status": "ok",
"timestamp": "2026-07-27T00:00:00.000Z",
"services": { "database": "connected", "redis": "connected" }
}

503 Service Unavailable — same shape with status: "unhealthy" and the unreachable dependency reported "disconnected".

Prometheus text-exposition metrics: default Node.js/process metrics plus application counters (login attempts by outcome, tokens issued by type and grant). No authentication — put it behind network-level access control in production.


Email/password endpoints for end users of your own application. Third-party / MCP clients use the OAuth endpoints instead.

Register a password account. The answer is the same for every address, whether it was free or already has an account, so it reveals neither. What happens next arrives by email (the mock provider delivers nothing):

  • A free address gets an unverified account and a verification link.
  • An address that already has a verified account gets no new account. Its owner gets a notice that someone tried to register it, with no link in it.
  • An address whose account is still unverified gets its verification link again. The link verifies only with the password chosen when the account was first registered (see POST /auth/verify).

A mail to an existing account counts toward the same per-address caps as POST /auth/resend-verification, and every registration claims the minimum interval there, so a resend right after a registration answers 429 for any address. A new account’s verification mail is always sent.

Request (application/json)

FieldTypeRequiredNotes
emailstring (email)yesAt most 254 characters (RFC 5321).
passwordstringyesStrength enforced server-side (zxcvbn score).
realmIdstring (uuid)noDefaults to the server’s default realm.

202 Accepted (Cache-Control: no-store)

{ "message": "Registration received. Check your inbox for an email with the next step." }

The body carries no user id: anything that differed between a new account and an existing one would answer the question this endpoint must not. Show a “check your inbox” screen, never “account created” or “address taken”. With REQUIRE_VERIFIED_ACCOUNT on (the default), the account can sign in only after its owner has verified it; until then POST /auth/login answers it with the same 401 as a wrong password.

Errors: 400 (validation, or an unknown or disabled realmId), 422 (WEAK_PASSWORD, with feedback), 429 (rate limited). None of them depends on whether the address is registered. Before 2026-10-19 this endpoint answered 201 with the new user and 409 for a registered address (breaking change, listed in CHANGELOG.md).

Authenticate with email/password and receive tokens directly (first-party).

Request (application/json): { "email": "...", "password": "..." }. The address is at most 254 characters.

200 OK

{
"access_token": "eyJ…",
"refresh_token": "a1b2…(64 hex)",
"expires_in": 900,
"token_type": "Bearer"
}

Errors: 400 (validation), 401 (invalid credentials), 429 (rate limited). The 401 is the same for an unknown address, a wrong password, a disabled account and, with REQUIRE_VERIFIED_ACCOUNT on (the default), an account whose address is not verified yet: registering an address and then logging in must not tell a free address from a registered one. Renew the access token with the refresh_token grant at POST /oauth/token — there is no separate refresh endpoint.

Sign the user out everywhere. Ends the user’s hosted browser sessions (from /ui/login or wallet login, so /oauth/authorize shows the login page again), revokes every refresh token the user holds, at every client, invalidates the user’s unredeemed authorization codes, and denylists the access token the request was made with. Access tokens already issued to other clients are not reached: they stay valid until they expire (see what revocation reaches).

Headers: Authorization: Bearer <access_token> (required). Only the access token from POST /auth/login is accepted, expired or not. A token issued to any other client or audience is refused.

200 OK: { "success": true, "message": "Successfully logged out" }

Errors: 401 (missing/invalid bearer, or a token whose user no longer exists).

Confirm an email address with the token from the verification email and the account’s password. The token proves control of the mailbox. The password proves the person who registered. Both are required, so someone who registered with another person’s address cannot get it verified by that person clicking a link. This is a POST, so no fetch or prefetch of the emailed link can verify an address by itself. The link opens the developer portal’s verification page, which asks for the password.

Body: { "token": "<64-char hex string>", "password": "<the account's password>" }

200 OK: { "message": "...", "email": "dev@example.com" }

Errors: 400 (malformed token), 404/400 (unknown or expired token), 401 INVALID_CREDENTIALS (wrong password; the token is not consumed, so the request can be retried), 429 (rate limited, or the account is locked out: wrong passwords here count toward the same failed-login lockout as POST /auth/login, and the 429 carries Retry-After).

Re-send the verification email. Rate-limited per address (min-interval + per-window caps). POST /auth/register counts toward the same caps.

Request (application/json): { "email": "dev@example.com" }

200 OK: { "message": "..." } — returned even for unknown addresses (no account enumeration). Errors: 400 (not an address, or longer than 254 characters), 429 (too soon / over limit).

Start linking a wallet credential to the signed-in account (issue #238, ADR-004 / ADR-009 §5). Registered only when WALLET_FEDERATION_ENABLED is on; otherwise the path does not exist (404).

Requires a valid __Host-qauth_session cookie and an X-CSRF-Token header matching the per-session token returned by GET /consents/.

Request (application/json, optional): { "device": "this" | "other" } — where the wallet is (#405). this starts a same-device link: render invocation_uri as a deep link, and the link completes on the browser’s return leg at GET /ui/wallet-login/return rather than by polling. other, an empty body, or no body at all is cross-device: render a QR code and poll. Any other value is a 400 validation error. The generated OpenAPI document marks this request body as required — the generator’s default for any declared body schema — but a POST with no body at all is accepted and means device: "other".

200 OK: { "handle": "...", "invocation_uri": "openid4vp://...", "expires_at": 1730000000000 } — render invocation_uri as a QR code or deep link; it is opaque. Errors: 401 (no session), 400 (invalid_csrf_token, or an invalid device), 404 (no usable VerifierProfile), 500.

Poll a linking flow, and complete it once the wallet has responded. Requires the session cookie and the browser-binder cookie minted by the POST above; the session must be the same user that started the flow.

200 OK: { "status": "pending" | "linked" | "conflict" | "expired" | "rejected", "message"?: "..." }

On linked, a second user_credentials row (provider_type='wallet') now exists under the same users.id, keyed on the identifier the account already owned — so a later wallet sign-in returns tokens with the identical sub. A link started with device: "this" is never completed by this poll: it answers pending after the wallet has responded, linked once after the return leg has completed it in the same browser, and rejected if the redirect has not landed within 180 s or landed in a different browser (#405). conflict is the one specific outcome (the credential belongs to another account) and is only reachable under the issuer-scoped-claim strategy; every other failure renders one uniform refusal. See Wallet sign-in.


Full request/response detail and a worked end-to-end walkthrough live in the OAuth 2.1 Flow guide. Contract summary:

EndpointMethodBody typePurpose
/oauth/authorizeGET, POSTquery / formStart authorization_code + PKCE. POST mirrors GET with form-encoded params (OIDC Core §3.1.2.1) — browser-navigated either way, not an API call.
/oauth/tokenPOSTformauthorization_code / refresh_token / client_credentials / token-exchange (RFC 8693)
/oauth/introspectPOSTformToken introspection (RFC 7662) — confidential clients only
/oauth/userinfoGET, POST—OIDC UserInfo (Bearer header, or POST with a form-encoded access_token, RFC 6750 §2.2)
/oauth/registerPOSTJSONDynamic Client Registration (RFC 7591, open mode)
/oauth/revokePOSTformToken revocation (RFC 7009)

Key contract facts:

  • response_type is code only; code_challenge_method is S256 only (PKCE required).
  • Tokens carry iss, aud (RFC 8707 resource binding), exp, iat, and scope.
  • client_credentials tokens set sub = client_id and issue no refresh token.
  • Scopes are deny-by-default (client allowlist; DCR clients capped to the realm’s DEFAULT_DYNAMIC_REGISTRATION_SCOPES).
  • The urn:ietf:params:oauth:grant-type:token-exchange grant (RFC 8693, ADR-007 §2) lets an agent client delegate on behalf of a user: sub stays the user and an act claim names the agent (nested for chained delegation). Agent-only and default-deny; scope/audience are preserved or narrowed, never widened. See the Token Exchange section and the Agent Authorization guide.
  • Dynamic Client Registration (POST /oauth/register) accepts the optional QAuth extension field is_agent (boolean, default false) marking the client as an AI agent; it is echoed back in the response. The flag is self-asserted and untrusted — see Agent Authorization.

Token response (POST /oauth/token, 200 OK)

Section titled “Token response (POST /oauth/token, 200 OK)”
{
"access_token": "eyJ…",
"refresh_token": "a1b2…", // omitted for client_credentials, token-exchange, and a code approved without a stored consent
"id_token": "eyJ…", // authorization_code grant only, when `openid` was granted
"expires_in": 900,
"token_type": "Bearer",
"scope": "openid profile email", // present when scopes granted
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token" // token-exchange only (RFC 8693 §2.2.1)
}

id_token is a separate, client-audienced (aud = your client_id) EdDSA JWT asserting the sign-in event: sub, nonce (when sent), auth_time, name (only under the profile scope, and only when set on the user), and email / email_verified (only under the email scope, same trust-ordered resolution as the UserInfo response below). The ID token and UserInfo release the same profile and email claims for the same grant. Only the authorization_code grant issues one; refresh_token does not reissue it. See ID token claims for the full list.

Introspection response (POST /oauth/introspect, 200 OK)

Section titled “Introspection response (POST /oauth/introspect, 200 OK)”
{
"active": true,
"sub": "...",
"client_id": "...",
"scope": "mcp:read",
"aud": "http://localhost:8088",
"iss": "http://localhost:3000",
"exp": 1750000000,
"iat": 1749999100,
"token_type": "Bearer"
}

An inactive/expired/unknown/wrong-audience token returns { "active": false }, and so does a user’s token once that user is disabled or deleted.

UserInfo response (GET|POST /oauth/userinfo, 200 OK)

Section titled “UserInfo response (GET|POST /oauth/userinfo, 200 OK)”
{ "sub": "...", "email": "dev@example.com", "email_verified": true }

email/email_verified are conditional: released only under the email scope and only when a verified email attribute exists (ADR-002 trust order wallet > oidc_* > self_reported). Otherwise both keys are absent (never null). When present, email_verified is always true. The POST form accepts the access token either as a Bearer header or as a form-encoded access_token field (RFC 6750 §2.2). A token whose user has been disabled since it was issued gets 401. So does a token that is not addressed to UserInfo: its aud must contain the token’s own client_id (the default when the grant named no resource) or the userinfo_endpoint URL from discovery. See UserInfo.

RFC 7009 token revocation. Confidential client authentication required (client_secret_basic / client_secret_post, the same as introspection).

Request (form-urlencoded): token (required), token_type_hint (access_token | refresh_token, optional/advisory), plus client credentials if not sent via HTTP Basic.

200 OK, empty body — always, per RFC 7009 §2.2. A refresh token revokes its whole rotation family; an access token is denylisted by jti for its remaining lifetime. A token the caller doesn’t own, or an unknown/invalid token, is a silent no-op (not an error), so the endpoint cannot be used to probe whether a token exists. The only non-200 outcome is a client authentication failure (invalid_client).


Unauthenticated and cacheable. The discovery documents carry Cache-Control: public, max-age=3600; the JWKS carries public, max-age=300.

EndpointReturns
GET /.well-known/oauth-authorization-serverOAuth 2.0 AS metadata (RFC 8414)
GET /.well-known/openid-configurationOIDC Discovery 1.0 (superset of the above)
GET /.well-known/jwks.jsonJWKS — active EdDSA public key(s) (RFC 7517)

Prefer discovering endpoint URLs from these documents over hard-coding paths. The AS metadata advertises resource_indicators_supported: true, authorization_response_iss_parameter_supported: true (RFC 9207 — /oauth/authorize returns iss on both success and error redirects, so the flag is not configurable), and, when enabled, client_id_metadata_document_supported: true (CIMD).


Server-rendered, cookie-authenticated pages back the browser leg of authorization_code — see Hosted UI for the full behaviour, including the pending-authorization mechanics behind the login bounce.

EndpointMethodPurpose
/ui/loginGET, POSTSession-cookie login page and form submission
/ui/consentGET, POSTOAuth consent screen and decision submission
/ui/resume/{handle}GETResume a pending authorization after login (single-use, 10-minute TTL)

The wallet-login screens below are registered only when WALLET_FEDERATION_ENABLED is on; with the flag off (the default) every path 404s. See Wallet sign-in for the flow they implement.

EndpointMethodPurpose
/ui/wallet-loginGET, POSTGET renders the ADR-009 identifier form; POST starts the sign-in. The form field device (this | other, absent ⇒ other) says where the wallet is: this renders the deep link, other the QR code (#405).
/ui/wallet-login/{handle}GETThe waiting screen for a pending presentation.
/ui/wallet-login/{handle}/statusGETPoll the presentation’s outcome. Completes a cross-device sign-in once the presentation lands; a same-device one only once its return leg has, and rejects it if the redirect never arrives.
/ui/wallet-login/returnGETThe same-device return leg: ?response_code= carries the single-use Response Code the wallet received from POST /oid4vp/response. Spent first, then bound to the browser that started the flow; every refusal renders one identical page (#405).
/ui/wallet-linkGET, POSTGET renders the confirmation screen; POST starts the link (issue #238) with the same device form field and the same one-affordance page as the sign-in.
/ui/wallet-link/{handle}GETThe waiting/outcome screen for a pending link.

Lets a signed-in user manage their own OAuth consent grants from the auth-server’s hosted UI. Cookie-authed (__Host-qauth_session), so it carries a CSRF token.

If you are calling from a separate origin — the developer portal, or your own front end — use /api/consents/ below instead. That cookie is set only by POST /ui/login and is SameSite=Lax, so it will not be attached to a cross-site fetch() whatever credentials: 'include' says.

List the signed-in user’s active consents. The response also carries a per-session CSRF token that must be echoed back as X-CSRF-Token on DELETE /consents/{id}.

200 OK

{
"consents": [
{
"id": "...",
"clientId": "...",
"clientName": "My App",
"scopes": ["openid"],
"grantedAt": 1750000000000
}
],
"csrfToken": "..."
}

Errors: 401 (no/invalid session) — returns { "consents": [] } with no csrfToken.

Revoke one consent owned by the signed-in user. The refresh tokens and the unredeemed authorization codes the user holds for that client are revoked with it. Access tokens already issued stay valid until they expire.

Headers: X-CSRF-Token: <csrfToken from GET /consents/> (required).

204 No Content. Errors: 400 (invalid_csrf_token — missing or mismatched X-CSRF-Token; the first outcome you’ll hit if the header isn’t wired up yet), 401 (no/invalid session), 404 (consent does not exist or is not owned by the caller).


The same consent management, Bearer-authed — the credential the developer portal and any other API client actually holds. Same auth model as /api/clients/: Authorization: Bearer <access_token>, scoped strictly to the token subject.

No CSRF token. A Bearer token is not ambient authority a cross-site page can make the browser attach, so there is nothing for one to add. The cookie-authed /consents/ above needs one precisely because its credential is ambient.

Ownership and auditing are shared with /consents/, so the two surfaces cannot diverge on who may revoke what.

List the authenticated user’s active consents.

Headers: Authorization: Bearer <access_token> (required).

200 OK

{
"consents": [
{
"id": "...",
"clientId": "...",
"clientName": "My App",
"scopes": ["openid"],
"grantedAt": 1750000000000
}
]
}

No csrfToken field — see above. Errors: 401 (missing/invalid bearer).

Revoke one consent owned by the authenticated user, with the same effect as DELETE /consents/{id}.

204 No Content. Errors: 401 (missing/invalid bearer), 404 (consent does not exist or is not owned by the caller — deliberately the same outcome, so the API never reveals that another user’s consent id exists).


Developer-portal API for managing a developer’s own OAuth clients. JSON, camelCase, and authenticated with a developer Authorization: Bearer access token (from POST /auth/login). Results are scoped to the token subject’s developer_id; the client secret is never returned.

List the authenticated developer’s OAuth clients.

Headers: Authorization: Bearer <access_token> (required).

200 OK

{
"clients": [
{
"id": "0190f7…",
"clientId": "0190f7a0-…-uuid",
"name": "My App",
"description": null,
"redirectUris": ["http://localhost:5173/callback"],
"scopes": ["openid", "profile"],
"grantTypes": ["authorization_code", "refresh_token"],
"responseTypes": ["code"],
"tokenEndpointAuthMethod": "none",
"enabled": true,
"suspended": false,
"requirePkce": true,
"createdAt": 1750000000000,
"updatedAt": 1750000000000,
"lastUsedAt": null
}
]
}

A developer with no clients gets { "clients": [] }. Errors: 401 (missing/invalid bearer).

enabled is the owner’s switch. suspended is read-only: true when the operator has suspended the client, which is then refused wherever a disabled client is (unauthorized_client at /oauth/authorize, invalid_client at the token, introspection and revocation endpoints), whatever enabled says. Only the operator lifts a suspension.

Ownership & 404 semantics. Every per-client route is scoped to the token subject’s developer_id. A client that exists but is owned by another developer is reported as 404 Not Found (not 403) so the API never confirms the existence of clients the caller does not own.

Create an OAuth client owned by the authenticated developer. The server generates the clientId (UUID) and, for confidential clients, a 32-byte clientSecret. The plaintext clientSecret is returned in this response only — only its argon2id hash is stored, so it is unrecoverable afterwards.

Headers: Authorization: Bearer <access_token> (required).

Body (all besides name optional):

FieldTypeDefaultNotes
namestring—Required, 1–255 chars.
descriptionstringnull
redirectUrisstring[][]At most 20. Each validated (OAuth 2.1 §10.3 — https, loopback http, or an RFC 8252 private-use scheme such as com.example.app:; any other scheme is refused). Required (≥1) for user-involving grants (authorization_code / refresh_token).
scopesstring[][]At most 100. Capped to the realm’s allowed-scopes policy (same allowlist as dynamic registration); a scope outside it is rejected.
grantTypesstring[]["authorization_code","refresh_token"]At most 8. authorization_code / refresh_token / client_credentials.
responseTypesstring[]["code"]At most 4. OAuth 2.1 only supports code.
tokenEndpointAuthMethodstring"none"none (public) / client_secret_post / client_secret_basic / private_key_jwt.

Rate limit: per-IP, shared budget with POST /oauth/register (REGISTER_CLIENT_RATE_LIMIT / REGISTER_CLIENT_RATE_WINDOW) — create runs an argon2id hash on every call, so the cap is mandatory (429 on exceed).

201 Created (Cache-Control: no-store)

{
"id": "0190f7…",
"clientId": "0190f7a0-…-uuid",
"name": "My App",
"description": null,
"redirectUris": ["https://app.example.com/cb"],
"scopes": ["openid"],
"grantTypes": ["authorization_code", "refresh_token"],
"responseTypes": ["code"],
"tokenEndpointAuthMethod": "client_secret_post",
"enabled": true,
"suspended": false,
"requirePkce": true,
"createdAt": 1750000000000,
"updatedAt": 1750000000000,
"lastUsedAt": null,
"clientSecret": "a1b2c3…(64 hex chars, shown once)"
}

Public clients (tokenEndpointAuthMethod: "none") get no clientSecret. A repeated scope is stored once. A client created here is not operator-provisioned: an authorization error that /oauth/authorize would send before the user sees any QAuth page (for example login_required for prompt=none) is shown on an issuer page with a link to the redirect URI, not redirected automatically (see Errors). A developer may own at most MAX_CLIENTS_PER_DEVELOPER clients (default 100), counting the ones registered with attribution through POST /oauth/register. Errors: 400 (invalid redirectUri, inconsistent grant/response types, missing redirectUris for a user-involving grant, or a scope outside the realm policy), 401 (missing/invalid bearer, or a non-user token), 403 (the developer already owns MAX_CLIENTS_PER_DEVELOPER clients), 429 (rate limited).

Get one of the developer’s clients. Safe fields only — never the secret.

200 OK — the same shape as a GET /api/clients/ list item. Errors: 401; 404 (not found or not owned).

Partially update a client. Any subset of: name, description, redirectUris, scopes, grantTypes, responseTypes, tokenEndpointAuthMethod, enabled, with the same limits as on create. clientId, the secret, and developerId are immutable here (unknown/immutable keys are ignored). The effective configuration (request value or persisted value) is re-validated: grant/response-type consistency, a redirect URI for user-involving grants, and the realm scope cap when scopes is changed.

enabled is the owner’s switch and may be set either way. It does not lift an operator’s suspension: suspended cannot be written here, and a suspended client stays refused after "enabled": true until the operator lifts it.

200 OK — the updated client (safe fields, no secret). Errors: 400 (validation / inconsistent config / disallowed scope / missing redirect for a user-involving grant), 401, 404.

Delete a client. After deletion the client can no longer authenticate at the token endpoint and cannot start new authorization flows. Note: already-issued access tokens are stateless JWTs and remain valid until they expire; short access-token lifetimes bound this window.

204 No Content. Errors: 401; 404 (not found or not owned).

Issue a new clientSecret. The previous secret is invalidated immediately; the new plaintext secret is returned in this response only.

200 OK (Cache-Control: no-store) — the client (safe fields) plus a clientSecret string. Errors: 400 (public client — no secret to rotate), 401, 404, 429 (rate limited — argon2id, same per-IP budget as create).

Clients may also be obtained outside this API: via CIMD, the mechanism MCP Authorization 2026-07-28 says to prefer; via Dynamic Client Registration (POST /oauth/register), which that revision deprecates but QAuth still supports; or via the seed-oauth-clients script.


API keys (/api/clients/{clientId}/api-keys)

Section titled “API keys (/api/clients/{clientId}/api-keys)”

Static, long-lived developer API keys scoped to one of the developer’s own OAuth clients — an alternative to client_credentials for development-only use. Same Bearer developer authentication and ownership/404 rules as client management, above.

Environment-gated (ADR-008 §6). Minting a key is permitted only while the client resolves to a development environment; a staging/production (or unset-environment) client is refused with 403. Use the OAuth client_credentials grant instead for anything beyond local development.

Mint a static API key for the client.

Request (application/json): { "name": "..." } (1–255 chars).

201 Created

{
"id": "...",
"clientId": "...",
"name": "local dev",
"prefix": "...",
"last4": "...",
"createdAt": 1750000000000,
"lastUsedAt": null,
"revokedAt": null,
"key": "...(plaintext, shown once)"
}

Errors: 400, 401, 403 (client not in a development environment), 404.

List the client’s API keys — masked fields only (prefix + last4, never the key or its hash). Includes revoked keys (revokedAt set).

200 OK: { "apiKeys": [ { "id": "...", "clientId": "...", "name": "...", "prefix": "...", "last4": "...", "createdAt": 0, "lastUsedAt": null, "revokedAt": null } ] }

Errors: 401, 404.

DELETE /api/clients/{clientId}/api-keys/{keyId}

Section titled “DELETE /api/clients/{clientId}/api-keys/{keyId}”

Revoke one API key. Idempotent soft-delete — the row is retained with revokedAt set, and a revoked key never authenticates again.

200 OK — the revoked key (masked fields, revokedAt set). Errors: 401, 404 (client or key not found / not owned).


Wallet federation transport (/oid4vp/request/{handle}, /oid4vp/response)

Section titled “Wallet federation transport (/oid4vp/request/{handle}, /oid4vp/response)”

Flag-gated, off by default. POST /oid4vp/response is registered only when WALLET_FEDERATION_ENABLED=true and a VerifierProfile is configured for the deployment; both default off, and with either missing the route does not exist (404) or fails closed. See the Status page for where wallet federation stands overall.

Even when enabled, this endpoint is transport only. It is the OID4VP 1.0 direct_post Response Endpoint: it structurally parses and correlates a wallet’s Authorization Response (vp_token + state, or error + state) against a single-use presentation request. It performs no signature, credential, or issuer validation, and authenticates no user.

Request (form-urlencoded): state (required), vp_token, error, error_description.

200 OK — a transport-level acknowledgement: a JSON object, sent Cache-Control: no-store (OID4VP 1.0 §8.2). For a cross-device request the object is empty, {}, and the wallet is not required to do anything further. For a request the user started as same-device it is { "redirect_uri": "https://<issuer>/ui/wallet-login/return?response_code=<43 base64url chars>" } — an absolute URI under the issuer carrying a fresh, single-use Response Code, which the wallet MUST redirect the user agent to (HAIP 1.0 §5.1); it is returned on the accepted path and on a wallet-reported error alike. Either way the acknowledgement confirms only that the response was well-formed and correlated. It does not assert that any credential was verified or any user authenticated — whoever follows the redirect_uri must also hold the browser binder of the flow the code names, and the return route decides that, not this body. Cross-device (QR) flows are unchanged by #405.

Flag-gated on the same variable, and registered only alongside the endpoint above. This is the JAR Request Object Endpoint (RFC 9101 §5.2.2, HAIP 1.0 §5.1): under a VerifierProfile that mandates signed Authorization Requests, the wallet invocation URI carries a request_uri pointing here instead of the request parameters inline, and the wallet fetches the signed request object from it (#377).

200 OK — the compact JWS, served as application/oauth-authz-req+jwt with Cache-Control: no-store. Its x5c header carries the Verifier’s leaf certificate and any intermediates, with the trust anchor excluded; client_id is the base64url SHA-256 of that leaf.

It consumes nothing and writes nothing. The request state is not redeemed here — a wallet may legitimately retry the fetch — so single use stays enforced where it belongs, at POST /oid4vp/response. An unknown, expired or malformed handle all produce the same bare 404, so the endpoint is not an oracle for which requests exist.