Download OpenAPI specification:
Account/organization management, API key issuance, and multi-format credential issuance/verification for the VC Platform rebuild (see /requirements/PRD.md §5.1-§5.8). Covers everything implemented through Phase 2: org/membership CRUD, API keys, credential templates, four credential formats (VC-JWT, W3C VC 2.0 as JWT, W3C VC 1.1 with JSON-LD Data Integrity proofs, SD-JWT VC) over both the direct REST API and OpenID4VCI/VP 1.0 Final, Bitstring Status List revocation, and the queryable audit trail. Billing and webhooks are scaffolded but not yet built (see /requirements/ROADMAP.md).
Creates the org and adds the caller as its first admin member, in one transaction. No RLS exemption needed: the org id is generated app-side and the tenant context is set to it before the insert.
| name required | string [ 1 .. 200 ] characters |
| slug required | string^[a-z0-9][a-z0-9-]{1,62}$ Lowercase alphanumeric with hyphens, 2-63 chars, starting alphanumeric. |
{- "name": "string",
- "slug": "string"
}{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "slug": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Pre-tenant lookup via the list_user_memberships() database function — runs before any org context exists.
[- {
- "orgId": "25b2c2d5-a7fc-47d0-89e4-8709a1560bfa",
- "orgName": "string",
- "orgSlug": "string",
- "role": "admin"
}
]Any member may list. A non-member gets the same 403 as a nonexistent org id — the membership check doubles as the tenant-existence check.
| orgId required | string <uuid> |
[- {
- "userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
- "email": "user@example.com",
- "displayName": "string",
- "role": "admin",
- "since": "2019-08-24T14:15:22Z"
}
]Admin-only. Upserts the invited user by email, then adds the membership.
| orgId required | string <uuid> |
| email required | string <email> |
| role required | string (OrgRole) Enum: "admin" "developer" "auditor" "billing" Org roles per PRD §5.1. |
| displayName | string [ 1 .. 200 ] characters |
{- "email": "user@example.com",
- "role": "admin",
- "displayName": "string"
}{- "userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
- "email": "user@example.com",
- "role": "admin"
}Admin-only. Refuses to demote the last remaining admin.
| orgId required | string <uuid> |
| userId required | string <uuid> |
| role required | string (OrgRole) Enum: "admin" "developer" "auditor" "billing" Org roles per PRD §5.1. |
{- "role": "admin"
}{- "orgId": "25b2c2d5-a7fc-47d0-89e4-8709a1560bfa",
- "userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
- "role": "admin",
- "createdAt": "2019-08-24T14:15:22Z"
}Admin-only. The plaintext secret is returned exactly once in this response — only its SHA-256 hash is persisted. There is no way to retrieve it again after this call.
| orgId required | string <uuid> |
| name required | string [ 1 .. 200 ] characters |
| scopes required | Array of strings (ApiKeyScope) non-empty Items Enum: "issue" "verify" "admin" |
| expiresAt | string <date-time> ISO 8601 timestamp. Omit for a key that doesn't expire. |
{- "name": "string",
- "scopes": [
- "issue"
], - "expiresAt": "2019-08-24T14:15:22Z"
}{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "keyPrefix": "string",
- "scopes": [
- "issue"
], - "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "expiresAt": "2019-08-24T14:15:22Z",
- "lastUsedAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z",
- "secret": "string"
}Any member may list. Never includes the secret or its hash.
| orgId required | string <uuid> |
[- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "name": "string",
- "keyPrefix": "string",
- "scopes": [
- "issue"
], - "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e",
- "createdAt": "2019-08-24T14:15:22Z",
- "expiresAt": "2019-08-24T14:15:22Z",
- "lastUsedAt": "2019-08-24T14:15:22Z",
- "revokedAt": "2019-08-24T14:15:22Z"
}
]Admin-only. Idempotent — revoking an already-revoked key returns 204 without recording a second audit event.
| orgId required | string <uuid> |
| keyId required | string <uuid> |
{- "statusCode": 0,
- "error": "string",
- "message": "string"
}Admin-only. claimsSchema is stored but not yet enforced against
subjectClaims at issuance time. formats declares which wire
formats this template may be rendered in — declaring an
unimplemented format (e.g. mso_mdoc) is allowed as intent, but
requesting it at issuance fails with 400, not at creation time.
The same applies to statusMechanism: bitstring-status-list is
implemented, token-status-list is declarable but rejected at
issuance.
| orgId required | string <uuid> |
| name required | string [ 1 .. 200 ] characters |
| claimsSchema required | object A JSON Schema the eventual subjectClaims are meant to satisfy — accepted and stored, not yet validated against at issuance. |
| formats required | Array of strings (CredentialFormat) non-empty Items Enum: "vc+jwt" "vc11+ld" "vc20" "dc+sd-jwt" "mso_mdoc" |
| validityPeriodDays | integer >= 1 If set, an issued credential's validUntil is validFrom + this many days. Omit for a credential that doesn't expire. |
| statusMechanism | string Enum: "bitstring-status-list" "token-status-list" Omit for irrevocable (expiry-only) credentials. When set, issuance allocates a status entry and embeds credentialStatus in the rendered credential. |
{- "name": "string",
- "claimsSchema": { },
- "formats": [
- "vc+jwt"
], - "validityPeriodDays": 1,
- "statusMechanism": "bitstring-status-list"
}{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "orgId": "25b2c2d5-a7fc-47d0-89e4-8709a1560bfa",
- "name": "string",
- "claimsSchema": { },
- "formats": [
- "vc+jwt"
], - "validityPeriodDays": 0,
- "statusMechanism": "bitstring-status-list",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}Any member may list.
| orgId required | string <uuid> |
[- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "orgId": "25b2c2d5-a7fc-47d0-89e4-8709a1560bfa",
- "name": "string",
- "claimsSchema": { },
- "formats": [
- "vc+jwt"
], - "validityPeriodDays": 0,
- "statusMechanism": "bitstring-status-list",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}
]Any member may read. A nonexistent or foreign-org template id gets 404 — same no-leak pattern as everywhere else org-scoped.
| orgId required | string <uuid> |
| templateId required | string <uuid> |
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "orgId": "25b2c2d5-a7fc-47d0-89e4-8709a1560bfa",
- "name": "string",
- "claimsSchema": { },
- "formats": [
- "vc+jwt"
], - "validityPeriodDays": 0,
- "statusMechanism": "bitstring-status-list",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}API-key-authenticated (issue scope), not DevUserAuth — there is
deliberately no {orgId} in this path; the API key itself is the
tenant selector, the same way a Stripe key implies your account.
The org's Ed25519 signing key is provisioned lazily on first issuance (one key per org for Phase 0) and reused after. The platform constructs and encodes the JWS header/payload itself and sends only opaque signing-input bytes to the isolated signing service — it never sees credential content, and the platform never sees the private key (Architecture §5).
| templateId required | string <uuid> |
| subjectClaims required | object Not validated against the template's claimsSchema yet (Phase 0). |
| format | string Enum: "vc+jwt" "vc11+ld" "vc20" "dc+sd-jwt" "mso_mdoc" Which of the template's declared formats to render. Defaults to
|
{- "templateId": "196100ac-4eec-4fb6-a7f7-86c8b584771d",
- "subjectClaims": { },
- "format": "vc+jwt"
}{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "format": "vc+jwt",
- "credential": "string",
- "issuedAt": "2019-08-24T14:15:22Z",
- "validFrom": "2019-08-24T14:15:22Z",
- "validUntil": "2019-08-24T14:15:22Z"
}API-key-authenticated (verify scope). Signature verification
never calls the signing service — the issuer identifier is a
did:jwk, which embeds the public key directly, so resolving it is
pure decoding, not a network round trip. Always returns 201 with a
verified boolean and an errors array, whether the credential is
valid or not — a malformed or invalid credential is a successful
API call that reports failure, not an HTTP error. Audited either
way (PRD §5.8's "pass/fail + reason").
Credentials carrying a credentialStatus entry additionally get a
revocation check after signature verification, and it fails closed:
a flipped bit yields revoked, and a status list that cannot be
fetched or signature-verified yields status_unavailable — both
are verification failures, since "could not determine revocation
state" must not read as "valid". The platform's own lists are read
locally; third-party lists are fetched over HTTP and
signature-verified before a single bit is trusted.
| credential required | string non-empty The wire credential to verify — compact JWT ( |
{- "credential": "string"
}{- "verified": true,
- "format": "vc+jwt",
- "errors": [
- {
- "code": "signature_invalid",
- "message": "string"
}
], - "credential": {
- "id": "string",
- "orgId": "string",
- "templateId": "string",
- "issuerId": "string",
- "subjectClaims": { },
- "validFrom": "2019-08-24T14:15:22Z",
- "validUntil": "2019-08-24T14:15:22Z"
}
}Bitstring Status List v1.0 revocation (PRD §5.6, Architecture §4).
Templates that declare statusMechanism: bitstring-status-list get a
status entry allocated at issuance and a credentialStatus object
embedded in the rendered credential (vc+jwt and vc20 formats).
Revocation flips the credential's bit; verification then reports
revoked.
The status list itself is served publicly at the host root (not under
/api), because its URL is baked verbatim into issued credentials as
statusListCredential and any relying party must be able to fetch it:
GET /status-lists/{orgId}/{listId} — the signed status list
credential, a W3C VC 2.0 secured as a JWT (verifiable exactly like
any vc20 credential this platform issues). Reveals only bit
positions — never which credential or subject an index belongs to.API-key-authenticated (issue scope — the key that may create an
org's credentials is the one that may end them; a verify-only key
can do neither). Flips the credential's bit on its status list and
stamps revokedAt, in one transaction, audited as
credential.revoked. Idempotent: revoking an already-revoked
credential returns the original revocation without a second audit
event. Only credentials whose template declared a statusMechanism
can be revoked — anything else can only expire.
| credentialId required | string <uuid> |
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "revoked": true,
- "revokedAt": "2019-08-24T14:15:22Z"
}Readable by admin and auditor members — the auditor role is the
PRD §4 compliance persona, and this endpoint is what it exists for.
Newest-first, offset-paginated; hasMore is computed by fetching
one extra row, not by counting an append-only table. Rows are
immutable at the database layer (the app role has no UPDATE/DELETE
grants on audit_events).
| orgId required | string <uuid> |
| action | string Exact match on the dotted action, e.g. |
| resourceType | string e.g. |
| from | string <date-time> Inclusive lower bound on the event timestamp. |
| to | string <date-time> Exclusive upper bound on the event timestamp. |
| limit | integer [ 1 .. 200 ] Default: 50 |
| offset | integer >= 0 Default: 0 |
{- "events": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "orgId": "25b2c2d5-a7fc-47d0-89e4-8709a1560bfa",
- "actorType": "user",
- "actorId": "d2f1b55c-8cee-4a0d-af57-aebaeff77518",
- "action": "string",
- "resourceType": "string",
- "resourceId": "string",
- "detail": { },
- "createdAt": "2019-08-24T14:15:22Z"
}
], - "limit": 0,
- "offset": 0,
- "hasMore": true
}OpenID4VCI 1.0 Final issuance (PRD §5.4, Architecture §4). Only offer
creation lives under /api — the wallet-facing protocol endpoints sit at
the host root, because every org is its own credential issuer at
{publicUrl}/oid4vci/{orgId}:
GET /.well-known/openid-credential-issuer/oid4vci/{orgId} — issuer metadataGET /.well-known/oauth-authorization-server/oid4vci/{orgId} — AS metadataGET /oid4vci/{orgId}/jwks — org public key set (access-token verification)POST /oid4vci/{orgId}/token — pre-authorized code → access tokenPOST /oid4vci/{orgId}/nonce — c_nonce endpoint (Final's dedicated nonce endpoint)POST /oid4vci/{orgId}/credential — proof-of-possession → SD-JWT VCWire formats for those are defined by the OpenID4VCI / OAuth2 specs,
not re-documented here; conforming wallets discover everything from
the credential offer URI returned by POST /credentials/offers.
Starts the OpenID4VCI pre-authorized code flow: the org's backend
(API key, issue scope) states what to issue; the response's
credentialOffer URI goes to the holder as a QR code / deep link,
and their wallet drives the rest against the wallet-facing endpoints
(see the OpenID4VCI tag). The offer expires after expiresIn
seconds, the pre-authorized code is single-use, and the credential
is bound to the wallet's proof-of-possession key (cnf) at the
credential endpoint — subject claims are fixed here, at offer time.
| templateId required | string <uuid> |
| subjectClaims required | object Fixed at offer time — the wallet receives exactly these claims. |
| format | string Enum: "vc+jwt" "vc11+ld" "vc20" "dc+sd-jwt" "mso_mdoc" Defaults to dc+sd-jwt, the only OpenID4VCI-offerable format so far. |
{- "templateId": "196100ac-4eec-4fb6-a7f7-86c8b584771d",
- "subjectClaims": { },
- "format": "vc+jwt"
}{- "credentialOffer": "string",
- "credentialOfferObject": { },
- "expiresIn": 0
}OpenID4VP 1.0 Final relying-party flow with DCQL (PRD §5.5,
Architecture §4). The org's backend creates a presentation request
(unsigned, by-value, redirect_uri client-id prefix), shows the
returned openid4vp:// URI to the holder, and polls the request for
the outcome. The wallet answers by direct_post to the wallet-facing
endpoint at the host root:
POST /oid4vp/{orgId}/response/{requestId} — authorization response
(vp_token with DCQL-keyed SD-JWT VC presentations)Presentations must carry a key-binding JWT: its signature is checked
against the credential's cnf key, and its nonce/aud must match this
exact request — a presentation captured from one exchange cannot be
replayed into another.
Starts an OpenID4VP exchange (API key, verify scope): names the
credential type being asked for (by template) and optionally which
claims the wallet should disclose. Poll the returned id for the
outcome; the request expires after expiresIn seconds.
| templateId required | string <uuid> Template whose credential type (vct = template name) is being asked for. |
| requestedClaims | Array of strings Claim names the wallet is asked to disclose. Omitted = the wallet decides. |
{- "templateId": "196100ac-4eec-4fb6-a7f7-86c8b584771d",
- "requestedClaims": [
- "string"
]
}{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "authorizationRequest": "string",
- "expiresIn": 0
}| requestId required | string <uuid> |
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "status": "pending",
- "vct": "string",
- "templateId": "196100ac-4eec-4fb6-a7f7-86c8b584771d",
- "result": {
- "verified": true,
- "disclosedClaims": { },
- "issuerId": "string",
- "errors": [
- {
- "code": "signature_invalid",
- "message": "string"
}
]
}
}