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). |
| 2026-10-17 | v1, v2 | Documented unsigned Soroban transaction responses for state-changing routes. |
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" }202 Accepted
{
"status": "pending_signature",
"message": "Share link creation is prepared; sign and submit the transaction before using the link",
"transaction_xdr": "AAAAAgAAA…",
"network_passphrase": "Public Global Stellar Network ; September 2015",
"permission": "view_only",
"password_protected": true,
"credential_id": 42
}No share token is returned until the signed transaction is submitted and the link exists on-chain. 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.
202 Accepted
{
"status": "pending_signature",
"message": "Share link revocation is prepared; sign and submit the transaction before the link is revoked",
"transaction_xdr": "AAAAAgAAA…",
"network_passphrase": "Public Global Stellar Network ; September 2015"
}These routes remain keyless: transaction_xdr is an unsigned transaction for
the acting Stellar account to sign and submit. 202 Accepted means the
transaction is prepared, not that contract state changed. Use the corresponding
read endpoint after transaction submission to confirm the on-chain state.
202 Accepted — consent is not active until submitted:
{
"status": "pending_signature",
"message": "Consent grant is prepared; the holder must sign and submit the transaction before access is granted",
"transaction_xdr": "AAAAAgAAA…",
"network_passphrase": "Public Global Stellar Network ; September 2015",
"credential_id": 42,
"holder": "GBVZ…LM4H",
"verifier": "GD7H…4567",
"expires_at": 1759139700
}202 Accepted — consent remains active until submitted:
{
"status": "pending_signature",
"message": "Consent revocation is prepared; the holder must sign and submit the transaction before access is revoked",
"transaction_xdr": "AAAAAgAAA…",
"network_passphrase": "Public Global Stellar Network ; September 2015",
"credential_id": 42,
"verifier": "GD7H…4567"
}202 Accepted — no on-chain anchor ID is assigned before submission:
{
"status": "pending_signature",
"message": "Anchor registration is prepared; the admin must sign and submit the transaction before it is on-chain",
"transaction_xdr": "AAAAAgAAA…",
"network_passphrase": "Public Global Stellar Network ; September 2015",
"anchor": { "credentialId": 42, "verified": false }
}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.
202 Accepted
{
"total": 2,
"succeeded": 0,
"failed": 0,
"pending": 2,
"transaction_xdr": "AAAAAgAAA…",
"network_passphrase": "Public Global Stellar Network ; September 2015",
"results": [
{ "credential_id": "42", "status": "pending_signature" },
{ "credential_id": "43", "status": "pending_signature" }
]
}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.