Skip to content

Latest commit

 

History

History
871 lines (752 loc) · 22.2 KB

api-response-examples.md

File metadata and controls

871 lines (752 loc) · 22.2 KB

API Response Examples

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.


Contents

  1. Conventions
  2. Response example versioning
  3. Shared response schemas
  4. Error responses
  5. Endpoint examples
  6. Keeping examples up to date

Conventions

  • Base URL in examples: http://localhost:3000.
  • All bodies are application/json unless noted. Error bodies are application/problem+json (see Error responses).
  • u64 / i128 values returned from Soroban are serialized as strings (the server converts BigInt → 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 _at that 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.

Response example versioning

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

v1 → v2 response differences

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.

Versioning rules for this document

  1. A non-breaking change (new optional field) updates the example in place and adds a line to the changelog below.
  2. 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.
  3. Every PR that changes a response shape in api-server/src/routes/ must update this file in the same PR.

Changelog

Date Version Change
2026-09-26 v1, v2 Initial response catalog (issue #1636).

Shared response schemas

Schemas are given in TypeScript notation; string IDs are decimal-encoded u64.

Credential

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;
}

QuorumSlice

interface QuorumSlice {
  id: string;                 // u64
  creator: string;            // Stellar address
  attestors: string[];        // Stellar addresses
  weights: number[];          // 1–100, parallel to `attestors`
  threshold: number;
  version?: number;
}

CursorPage<T>

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;
  };
}

ProblemDetails (errors)

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;
}

BatchVerificationResponse

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;
  };
}

Error responses

All error responses follow RFC 9457 Problem Details (api-server/src/middleware/problemDetails.ts). The error field is kept for v1 clients.

Status code catalogue

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

Examples

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.


Endpoint examples

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.

Health

GET /health

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".

GET /health/ready

200 OK

{ "ready": true, "checks": { "database": true, "soroban_rpc": true } }

Errors: 503 with "ready": false.

GET /health/live

200 OK

{ "alive": true, "timestamp": "2026-09-26T10:15:00.000Z" }

Credentials

GET /api/credentials/search?q=&type=&status=&limit=&sort_by=&filter=

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 100
  • 400 — sort_by must be one of: …
  • 400 — invalid filter expression

GET /api/credentials/:id

200 OK — a Credential.

Errors: 400 Invalid credential ID, 404 Credential not found.

GET /api/credentials/:id/export?format=json|pdf|qrcode

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.

GET /api/credentials/:id/audit

200 OK

{
  "credential_id": "42",
  "entries": [
    { "action": "issued", "actor": "GCEZ…62RK", "ledger": 1048576, "timestamp": 1758880500 },
    { "action": "attested", "actor": "GDQP…7XQ4", "ledger": 1048580, "timestamp": 1758880520 }
  ]
}

Share links

POST /api/credentials/:id/share

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 ID
  • 400 subject (Stellar address) is required
  • 400 expiry_hours must be an integer between 1 and 8760
  • 400 permission must be "view_only" or "download"
  • 403 Only the credential holder can create share links
  • 404 Credential not found

POST /api/credentials/share/validate

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.

DELETE /api/credentials/share/:token

200 OK

{ "revoked": true, "token": "000000000000002a…" }

Verification

POST /api/verify/batch

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.

Quorum slices

GET /api/slices?cursor=&limit=

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.

GET /api/slices/:id

200 OK — a QuorumSlice.

Errors: 400 Invalid slice ID, 404 Slice not found.

GET /api/slices/:id/verification

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.

POST /api/slices/:id/invalidate

200 OK

{
  "invalidated": true,
  "metrics": { "hits": 120, "misses": 14, "evictions": 0, "size": 9 }
}

GET /api/slices/verification/metrics

200 OK

{ "hits": 120, "misses": 14, "evictions": 0, "size": 9 }

Attestors

GET /api/attestor/pending?address=G…

200 OK

{
  "attestor": "GDQPMRXHVK3GLR4VJMZ3YHXKJQ5Z4S6WNBTXPFXEV3OQZ5CTXV7XQ4",
  "pending": [
    { "credential_id": "42", "slice_id": "1", "requested_at": 1758880500 }
  ]
}

GET /api/attestor/reputation/:address

200 OK

{
  "address": "GDQPMRXHVK3GLR4VJMZ3YHXKJQ5Z4S6WNBTXPFXEV3OQZ5CTXV7XQ4",
  "attestations": 314,
  "disputes": 2,
  "score": 0.97
}

Errors: 400 invalid Stellar address.

POST /api/attestor/batch-attest

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 }
}

GET /api/attestors/:address/status

200 OK

{ "address": "GDQP…7XQ4", "available": true, "last_seen": "2026-09-26T10:10:00.000Z" }

Recovery

POST /api/recovery/request

201 Created

{ "request_id": "rec_7f3a2c", "status": "pending_otp", "expires_at": "2026-09-26T10:30:00.000Z" }

POST /api/recovery/verify-otp

200 OK

{ "request_id": "rec_7f3a2c", "status": "pending_approval" }

Errors: 400 invalid OTP, 404 Recovery request not found, 429 too many attempts.

GET /api/recovery/status/:requestId

200 OK

{
  "request_id": "rec_7f3a2c",
  "status": "pending_approval",
  "approvals": 1,
  "required_approvals": 2,
  "created_at": "2026-09-26T10:00:00.000Z"
}

POST /api/recovery/approve / POST /api/recovery/reject

200 OK

{ "request_id": "rec_7f3a2c", "status": "approved" }

Errors: 403 caller is not an approver, 404 Recovery request not found, 409 request already finalized.

Webhooks

POST /api/webhooks

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.

GET /api/webhooks

200 OK

[
  { "id": "wh_01J9Z3", "url": "https://example.com/hooks/quorumproof", "events": ["credential.issued"], "active": true }
]

GET /api/webhooks/dead-letters

200 OK

[
  { "id": "dl_8812", "webhook_id": "wh_01J9Z3", "event": "credential.issued", "attempts": 5, "last_error": "HTTP 502" }
]

POST /api/webhooks/dead-letters/:id/replay

200 OK

{ "replayed": true, "id": "dl_8812" }

DELETE /api/webhooks/:id

204 No Content. Errors: 404 webhook not found.

Outbound webhook payloads are signed — see ../api-server/docs/WEBHOOK_SIGNATURE_VERIFICATION.md.

API keys

POST /api/api-keys

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"
}

GET /api/api-keys/:id

200 OK — same as above without key; includes last_used_at.

POST /api/api-keys/:id/rotate

200 OK

{
  "id": "key_4c1e",
  "key": "qp_live_… (new, returned once)",
  "previous_key_valid_until": "2026-09-27T10:15:00.000Z"
}

GET /api/api-keys/:id/usage

200 OK

{ "id": "key_4c1e", "requests_24h": 1840, "errors_24h": 3, "last_used_at": "2026-09-26T10:14:58.000Z" }

DELETE /api/api-keys/:id

200 OK

{ "revoked": true, "id": "key_4c1e" }

Audit

GET /api/credentials/audit/entries?limit=&cursor= (audit router)

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
}

GET /api/credentials/audit/integrity

200 OK

{ "valid": true, "entries_checked": 451, "first_invalid_entry": null }

POST /api/credentials/audit/verify

200 OK

{ "valid": true, "entry_id": "aud_000451", "notarization_batch": "nb_0031" }

Analytics

GET /api/analytics/summary

200 OK

{
  "credentials_issued": 1204,
  "verifications": 9331,
  "verification_success_rate": 0.982,
  "active_issuers": 37,
  "window": "30d"
}

GET /api/analytics/issuer/:address

200 OK

{ "issuer": "GCEZ…62RK", "issued": 212, "revoked": 4, "verifications": 1830, "disputes": 1 }

Events

GET /api/events/recent?limit=

200 OK

{
  "events": [
    { "type": "credential.issued", "credential_id": "42", "ledger": 1048576, "timestamp": 1758880500 }
  ]
}

GET /api/events/ws-info

200 OK

{ "url": "ws://localhost:3000/ws", "protocols": ["qp-events-v1"], "heartbeat_ms": 30000 }

Admin

All admin endpoints require the admin:all permission and an allow-listed IP.

GET /api/admin/ip-whitelist

200 OK

{ "entries": ["10.0.0.0/8", "203.0.113.7"] }

POST /api/admin/ip-whitelist

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.

GET /api/admin/ip-acl/stats

200 OK

{ "allowed": 18230, "denied": 41, "protected_endpoints": 6 }

Keeping examples up to date

  • The source of truth for shapes is the route code under api-server/src/routes/ and the OpenAPI spec in api-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.