Issue #1636. Canonical success and error response examples for every public API-server endpoint group, plus the response schemas and the versioning rules that govern them.
For request-side walkthroughs (curl invocations, contract CLI calls) see api-endpoint-examples.md. For the generated OpenAPI reference see
../api-server/docs/API_DOCUMENTATION.md.
- Conventions
- Response example versioning
- Shared response schemas
- Error responses
- Endpoint examples
- Keeping examples up to date
- Base URL in examples:
http://localhost:3000. - All bodies are
application/jsonunless noted. Error bodies areapplication/problem+json(see Error responses). u64/i128values returned from Soroban are serialized as strings (the server convertsBigInt→ string before JSON encoding). Treat IDs, ledger numbers, and amounts as strings on the client.- Stellar addresses are 56-char
G…strings. Hashes are lowercase hex. - Timestamps are either Unix seconds (
number, fields ending in_atthat come from the contract) or ISO-8601 strings (fields generated by the API server, e.g.verified_at). - Examples below use the unversioned paths (
/api/...). These currently route to v1 semantics; see versioning for the v2 differences.
Every example in this file is tagged with the API version it was captured against. When a response shape changes, the old example is kept (marked deprecated) alongside the new one until the old API version is sunset.
| API version | Status | Path prefix | Examples tagged |
|---|---|---|---|
| v1 | Maintenance since 2026-09-01, sunset 2027-03-01 | /api/v1/... and unversioned /api/... |
v1 |
| v2 | GA since 2026-09-01 | /api/v2/... |
v2 |
| Aspect | v1 | v2 |
|---|---|---|
| Envelope | { "ok": true, "version": "v1", "data": { … } } on versioned routes |
Raw resource object, no envelope |
| Credential metadata field | metadata |
metadata_hash |
| Address field | address |
stellar_address |
| Pagination cursor | next_cursor |
cursor |
| Errors | { "error": "…" } (Problem Details fields added alongside) |
RFC 9457 Problem Details only |
Example — the same credential in both versions:
v1 — GET /api/v1/credentials/42
{
"ok": true,
"version": "v1",
"data": {
"id": "42",
"issuer": "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGZRXDMTGEYJ66SDOQ62RK",
"subject": "GBVZHJRZMJXHE3RQXEKW3KRJM45OZDXOLYG35PNZGJDAZOFMFKQLM4H",
"credential_type": 1,
"metadata": "a3f1b2c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2",
"revoked": false,
"expires_at": null
}
}v2 — GET /api/v2/credentials/42
{
"id": "42",
"issuer": "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGZRXDMTGEYJ66SDOQ62RK",
"subject": "GBVZHJRZMJXHE3RQXEKW3KRJM45OZDXOLYG35PNZGJDAZOFMFKQLM4H",
"credential_type": 1,
"metadata_hash": "a3f1b2c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2",
"revoked": false,
"expires_at": null
}v1 responses on deprecated routes also carry Deprecation and Sunset
headers; clients should log a warning when they see them.
- A non-breaking change (new optional field) updates the example in place and adds a line to the changelog below.
- A breaking change adds a new example under the new version tag and
marks the previous one
(deprecated — vN). Remove it only after that version's sunset date. - Every PR that changes a response shape in
api-server/src/routes/must update this file in the same PR.
| Date | Version | Change |
|---|---|---|
| 2026-09-26 | v1, v2 | Initial response catalog (issue #1636). |
Schemas are given in TypeScript notation; string IDs are decimal-encoded u64.
interface Credential {
id: string; // u64
issuer: string; // Stellar address
subject: string; // Stellar address
credential_type: number; // u32, see docs/credential-types.md
metadata_hash: string; // hex; `metadata` in v1
revoked: boolean;
suspended?: boolean;
expires_at: number | null; // Unix seconds
version?: number;
}interface QuorumSlice {
id: string; // u64
creator: string; // Stellar address
attestors: string[]; // Stellar addresses
weights: number[]; // 1–100, parallel to `attestors`
threshold: number;
version?: number;
}interface CursorPage<T> {
data: T[];
pagination: {
cursor: string | null; // cursor used for this page (base64)
next_cursor: string | null; // `cursor` in v2; null when no more pages
limit: number; // 1–100
total: number;
has_more: boolean;
};
}interface ProblemDetails {
type: string; // https://quorumproof.io/errors/<slug>
title: string; // e.g. "Not Found"
status: number; // mirrors the HTTP status
detail: string; // occurrence-specific message
error: string; // v1 compatibility alias of `detail`
instance?: string;
[extension: string]: unknown;
}type BatchVerificationStatus =
'verified' | 'failed' | 'not_found' | 'revoked' | 'expired' | 'error';
interface BatchVerificationResponse {
results: Array<{
credential_id: number;
claim_type: string;
status: BatchVerificationStatus;
proof: {
verified_at: string; // ISO-8601
credential_status: 'active' | 'revoked' | 'suspended' | 'expired';
digest: string; // 16 hex chars, correlation id — NOT a security primitive
} | null;
error: string | null;
}>;
summary: {
total: number;
verified: number;
failed: number;
not_found: number;
errors: number;
duplicates_deduplicated: number;
execution_time_ms: number;
};
}All error responses follow RFC 9457 Problem Details
(api-server/src/middleware/problemDetails.ts). The error field is kept for
v1 clients.
| Status | type slug |
When it happens |
|---|---|---|
| 400 | bad-request |
Malformed ID, invalid query parameter, schema validation failure |
| 401 | unauthorized |
Missing/invalid API key, bad request signature, expired OAuth token |
| 403 | forbidden |
Caller authenticated but lacks the RBAC permission or IP is not allow-listed |
| 404 | not-found |
Credential, slice, share token, webhook, or recovery request does not exist |
| 409 | conflict |
Duplicate request (request de-duplication) or state conflict |
| 410 | gone |
Share link expired/revoked; v1 route past sunset |
| 422 | unprocessable-entity |
Well-formed request that fails business rules |
| 429 | too-many-requests |
Rate limit, PoW challenge, or DDoS protection tripped |
| 500 | internal-server-error |
Unexpected server or Soroban RPC failure |
| 503 | service-unavailable |
Health check failing, circuit breaker open, contract paused |
400 — validation failure
{
"type": "https://quorumproof.io/errors/bad-request",
"title": "Bad Request",
"status": 400,
"detail": "limit must be between 1 and 100",
"error": "limit must be between 1 and 100"
}401 — missing API key
{
"type": "https://quorumproof.io/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "API key required",
"error": "API key required"
}403 — RBAC denial
{
"type": "https://quorumproof.io/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "Missing permission: admin:all",
"error": "Missing permission: admin:all"
}404 — resource not found
{
"type": "https://quorumproof.io/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Credential not found",
"error": "Credential not found"
}429 — rate limited (also sets Retry-After and RateLimit-* headers)
{
"type": "https://quorumproof.io/errors/too-many-requests",
"title": "Too Many Requests",
"status": 429,
"detail": "Rate limit exceeded. Retry after 30 seconds.",
"error": "Rate limit exceeded. Retry after 30 seconds.",
"retry_after": 30
}500 — upstream Soroban failure
{
"type": "https://quorumproof.io/errors/internal-server-error",
"title": "Internal Server Error",
"status": 500,
"detail": "Soroban RPC request failed",
"error": "Soroban RPC request failed"
}503 — unhealthy / paused
{
"type": "https://quorumproof.io/errors/service-unavailable",
"title": "Service Unavailable",
"status": 503,
"detail": "Contract is paused; try again later",
"error": "Contract is paused; try again later"
}Contract-level error names (
CredentialNotFound,SliceNotFound,UnauthorizedAction, …) are mapped to the HTTP statuses above. The full list of contract error codes is in error-codes.md.
Each endpoint lists its success response and the errors it can return. Tag
[v1] / [v2] marks the version the example was captured against; untagged
examples are identical in both versions apart from the differences listed in
v1 → v2 response differences.
200 OK (also returned when status is degraded)
{
"status": "healthy",
"timestamp": "2026-09-26T10:15:00.000Z",
"uptime_seconds": 86400,
"checks": {
"database": { "status": "healthy", "latency_ms": 3 },
"soroban_rpc": { "status": "healthy", "latency_ms": 48 },
"redis": { "status": "healthy", "latency_ms": 1 }
}
}Errors: 503 with the same body shape and "status": "unhealthy".
200 OK
{ "ready": true, "checks": { "database": true, "soroban_rpc": true } }Errors: 503 with "ready": false.
200 OK
{ "alive": true, "timestamp": "2026-09-26T10:15:00.000Z" }200 OK
{
"results": [
{
"id": "42",
"issuer": "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGZRXDMTGEYJ66SDOQ62RK",
"subject": "GBVZHJRZMJXHE3RQXEKW3KRJM45OZDXOLYG35PNZGJDAZOFMFKQLM4H",
"credential_type": 1,
"metadata_hash": "a3f1b2c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2",
"revoked": false,
"expires_at": null,
"version": 1
}
],
"total": 1,
"cursor": null
}Errors:
400—limit must be between 1 and 100400—sort_by must be one of: …400— invalidfilterexpression
200 OK — a Credential.
Errors: 400 Invalid credential ID, 404 Credential not found.
200 OK (format=json)
{
"credential": {
"id": "42",
"issuer": "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGZRXDMTGEYJ66SDOQ62RK",
"subject": "GBVZHJRZMJXHE3RQXEKW3KRJM45OZDXOLYG35PNZGJDAZOFMFKQLM4H",
"credential_type": 1,
"metadata_hash": "a3f1…f1a2",
"revoked": false,
"expires_at": null
},
"exported_at": "2026-09-26T10:15:00.000Z",
"format": "json"
}format=pdf returns application/pdf; format=qrcode returns image/png.
Errors: 400 unsupported format, 404 Credential not found.
200 OK
{
"credential_id": "42",
"entries": [
{ "action": "issued", "actor": "GCEZ…62RK", "ledger": 1048576, "timestamp": 1758880500 },
{ "action": "attested", "actor": "GDQP…7XQ4", "ledger": 1048580, "timestamp": 1758880520 }
]
}Request
{ "subject": "GBVZ…LM4H", "expiry_hours": 72, "permission": "view_only", "password": "optional" }201 Created
{
"token": "000000000000002a0000000068d7a3c4…",
"expires_at": 1759139700,
"permission": "view_only",
"password_protected": true,
"credential_id": 42
}Errors:
400 Invalid credential ID400 subject (Stellar address) is required400 expiry_hours must be an integer between 1 and 8760400 permission must be "view_only" or "download"403 Only the credential holder can create share links404 Credential not found
200 OK
{
"credential_id": 42,
"permission": "view_only",
"expires_at": 1759139700,
"created_by": "GBVZHJRZMJXHE3RQXEKW3KRJM45OZDXOLYG35PNZGJDAZOFMFKQLM4H"
}Errors: 400 malformed token, 401 wrong password, 410 expired or revoked link.
200 OK
{ "revoked": true, "token": "000000000000002a…" }Request
{
"items": [
{ "credential_id": 42, "claim_type": "HasDegree" },
{ "credential_id": 42, "claim_type": "HasDegree" },
{ "credential_id": 99, "claim_type": "HasLicense" }
]
}200 OK
{
"results": [
{
"credential_id": 42,
"claim_type": "HasDegree",
"status": "verified",
"proof": {
"verified_at": "2026-09-26T10:15:00.000Z",
"credential_status": "active",
"digest": "9f2c61a0b4e7d3c1"
},
"error": null
},
{
"credential_id": 42,
"claim_type": "HasDegree",
"status": "verified",
"proof": {
"verified_at": "2026-09-26T10:15:00.000Z",
"credential_status": "active",
"digest": "9f2c61a0b4e7d3c1"
},
"error": null
},
{
"credential_id": 99,
"claim_type": "HasLicense",
"status": "not_found",
"proof": null,
"error": "Credential not found"
}
],
"summary": {
"total": 3,
"verified": 2,
"failed": 0,
"not_found": 1,
"errors": 0,
"duplicates_deduplicated": 1,
"execution_time_ms": 84
}
}A revoked credential yields "status": "revoked" with
"credential_status": "revoked"; an expired one yields "status": "expired"
with "proof": null.
Errors: 400 schema validation (empty items, too many items, missing
claim_type), 429 rate limited.
200 OK — a CursorPage<QuorumSlice>. Supports
Accept: application/xml (root <slices>, items <slice>).
{
"data": [
{
"id": "1",
"creator": "GBVZHJRZMJXHE3RQXEKW3KRJM45OZDXOLYG35PNZGJDAZOFMFKQLM4H",
"attestors": [
"GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGZRXDMTGEYJ66SDOQ62RK",
"GDQPMRXHVK3GLR4VJMZ3YHXKJQ5Z4S6WNBTXPFXEV3OQZ5CTXV7XQ4"
],
"weights": [60, 40],
"threshold": 60
}
],
"pagination": {
"cursor": null,
"next_cursor": "MQ==",
"limit": 20,
"total": 7,
"has_more": true
}
}Errors: 400 Invalid cursor, 500 RPC failure.
200 OK — a QuorumSlice.
Errors: 400 Invalid slice ID, 404 Slice not found.
200 OK
{
"data": { "slice_id": "1", "quorum_reached": true, "total_weight": 100, "attested_weight": 60 },
"cached": false
}cached: true when served from the verification cache (unchanged slice version).
Errors: 400 Invalid slice ID, 404 Slice not found.
200 OK
{
"invalidated": true,
"metrics": { "hits": 120, "misses": 14, "evictions": 0, "size": 9 }
}200 OK
{ "hits": 120, "misses": 14, "evictions": 0, "size": 9 }200 OK
{
"attestor": "GDQPMRXHVK3GLR4VJMZ3YHXKJQ5Z4S6WNBTXPFXEV3OQZ5CTXV7XQ4",
"pending": [
{ "credential_id": "42", "slice_id": "1", "requested_at": 1758880500 }
]
}200 OK
{
"address": "GDQPMRXHVK3GLR4VJMZ3YHXKJQ5Z4S6WNBTXPFXEV3OQZ5CTXV7XQ4",
"attestations": 314,
"disputes": 2,
"score": 0.97
}Errors: 400 invalid Stellar address.
200 OK
{
"results": [
{ "credential_id": "42", "status": "attested", "tx_hash": "b7e1…9a0c" },
{ "credential_id": "43", "status": "failed", "error": "AlreadyAttested" }
],
"summary": { "total": 2, "succeeded": 1, "failed": 1 }
}200 OK
{ "address": "GDQP…7XQ4", "available": true, "last_seen": "2026-09-26T10:10:00.000Z" }201 Created
{ "request_id": "rec_7f3a2c", "status": "pending_otp", "expires_at": "2026-09-26T10:30:00.000Z" }200 OK
{ "request_id": "rec_7f3a2c", "status": "pending_approval" }Errors: 400 invalid OTP, 404 Recovery request not found, 429 too many attempts.
200 OK
{
"request_id": "rec_7f3a2c",
"status": "pending_approval",
"approvals": 1,
"required_approvals": 2,
"created_at": "2026-09-26T10:00:00.000Z"
}200 OK
{ "request_id": "rec_7f3a2c", "status": "approved" }Errors: 403 caller is not an approver, 404 Recovery request not found,
409 request already finalized.
201 Created
{
"id": "wh_01J9Z3",
"url": "https://example.com/hooks/quorumproof",
"events": ["credential.issued", "credential.revoked"],
"secret": "whsec_… (returned once)",
"created_at": "2026-09-26T10:15:00.000Z"
}Errors: 400 invalid URL or unknown event type.
200 OK
[
{ "id": "wh_01J9Z3", "url": "https://example.com/hooks/quorumproof", "events": ["credential.issued"], "active": true }
]200 OK
[
{ "id": "dl_8812", "webhook_id": "wh_01J9Z3", "event": "credential.issued", "attempts": 5, "last_error": "HTTP 502" }
]200 OK
{ "replayed": true, "id": "dl_8812" }204 No Content. Errors: 404 webhook not found.
Outbound webhook payloads are signed — see
../api-server/docs/WEBHOOK_SIGNATURE_VERIFICATION.md.
201 Created
{
"id": "key_4c1e",
"name": "ci-verifier",
"key": "qp_live_… (returned once)",
"scopes": ["credentials:read", "verify:write"],
"created_at": "2026-09-26T10:15:00.000Z",
"expires_at": "2027-09-26T10:15:00.000Z"
}200 OK — same as above without key; includes last_used_at.
200 OK
{
"id": "key_4c1e",
"key": "qp_live_… (new, returned once)",
"previous_key_valid_until": "2026-09-27T10:15:00.000Z"
}200 OK
{ "id": "key_4c1e", "requests_24h": 1840, "errors_24h": 3, "last_used_at": "2026-09-26T10:14:58.000Z" }200 OK
{ "revoked": true, "id": "key_4c1e" }200 OK
{
"entries": [
{
"id": "aud_000451",
"action": "credential.revoked",
"actor": "GCEZ…62RK",
"target": "credential:42",
"timestamp": "2026-09-26T10:15:00.000Z",
"prev_hash": "5d1e…",
"hash": "a09b…"
}
],
"next_cursor": null
}200 OK
{ "valid": true, "entries_checked": 451, "first_invalid_entry": null }200 OK
{ "valid": true, "entry_id": "aud_000451", "notarization_batch": "nb_0031" }200 OK
{
"credentials_issued": 1204,
"verifications": 9331,
"verification_success_rate": 0.982,
"active_issuers": 37,
"window": "30d"
}200 OK
{ "issuer": "GCEZ…62RK", "issued": 212, "revoked": 4, "verifications": 1830, "disputes": 1 }200 OK
{
"events": [
{ "type": "credential.issued", "credential_id": "42", "ledger": 1048576, "timestamp": 1758880500 }
]
}200 OK
{ "url": "ws://localhost:3000/ws", "protocols": ["qp-events-v1"], "heartbeat_ms": 30000 }All admin endpoints require the admin:all permission and an allow-listed IP.
200 OK
{ "entries": ["10.0.0.0/8", "203.0.113.7"] }201 Created
{ "added": "203.0.113.8", "entries": ["10.0.0.0/8", "203.0.113.7", "203.0.113.8"] }Errors: 400 invalid IP/CIDR, 403 Missing permission: admin:all.
200 OK
{ "allowed": 18230, "denied": 41, "protected_endpoints": 6 }- The source of truth for shapes is the route code under
api-server/src/routes/and the OpenAPI spec inapi-server/openapi/openapi.json. When they disagree with this file, the code wins — fix the example. - Values are illustrative; addresses and hashes are truncated with
…where the exact value is irrelevant. - When adding a new endpoint, add: one success example, every error status it can emit, and any new schema to Shared response schemas.
- Record the change in the changelog.